From 483eb0b2fbf4733fb4f78f7b437524b54ae5843c Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 26 Jun 2026 23:32:18 +0200 Subject: [PATCH] Feat: Hermes PC-Zugriff via MCP (executor + mcp_pc) - mcp/mcp_pc.py: MCP-Server der PC-Tools an den Executor proxied (pc_shell, pc_screenshot, pc_type, pc_key, pc_open, pc_status) - client/hermes-pc/executor.py: FastAPI auf Port 7777 (Daemon-Reg entfernt) - client/hermes-pc/start.bat, install.bat, requirements.txt - client/hermes-voice/agent_client.py, main.py: agent_client integration Co-Authored-By: Claude Sonnet 4.6 --- client/hermes-pc/executor.py | 132 ++++++++++++++++++++++++++++ client/hermes-pc/install.bat | 8 ++ client/hermes-pc/requirements.txt | 6 ++ client/hermes-pc/start.bat | 9 ++ client/hermes-voice/agent_client.py | 31 +++++++ client/hermes-voice/main.py | 9 +- mcp/mcp_pc.py | 114 ++++++++++++++++++++++++ 7 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 client/hermes-pc/executor.py create mode 100644 client/hermes-pc/install.bat create mode 100644 client/hermes-pc/requirements.txt create mode 100644 client/hermes-pc/start.bat create mode 100644 client/hermes-voice/agent_client.py create mode 100644 mcp/mcp_pc.py diff --git a/client/hermes-pc/executor.py b/client/hermes-pc/executor.py new file mode 100644 index 0000000..3138f3f --- /dev/null +++ b/client/hermes-pc/executor.py @@ -0,0 +1,132 @@ +""" +Hermes PC Executor — läuft auf dem Windows PC. +Empfängt Tool-Befehle vom MCP-Server der AI Box und führt sie lokal aus. +""" +import base64 +import io +import os +import socket +import subprocess +import webbrowser +from urllib.parse import quote + +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +app = FastAPI(title="Hermes PC Executor") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +MY_PORT = int(os.environ.get("HERMES_PC_PORT", "7777")) + + +# ── Shell ────────────────────────────────────────────────────────────────── + +@app.post("/shell") +async def run_shell(req: dict): + cmd = req.get("command", "") + try: + result = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], + capture_output=True, text=True, timeout=60, + encoding="utf-8", errors="replace", + ) + return { + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + "returncode": result.returncode, + } + except subprocess.TimeoutExpired: + raise HTTPException(408, "Timeout nach 60s") + except Exception as e: + raise HTTPException(500, str(e)) + + +# ── Screen ───────────────────────────────────────────────────────────────── + +@app.post("/screenshot") +async def take_screenshot(): + try: + import mss + from PIL import Image + + with mss.mss() as sct: + monitor = sct.monitors[1] + raw = sct.grab(monitor) + img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX") + img.thumbnail((1280, 720)) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=75) + return {"screenshot": base64.b64encode(buf.getvalue()).decode()} + except Exception as e: + raise HTTPException(500, str(e)) + + +# ── Input ────────────────────────────────────────────────────────────────── + +@app.post("/type") +async def type_text(req: dict): + text = req.get("text", "") + try: + import pyautogui + pyautogui.write(text, interval=0.03) + return {"ok": True} + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/key") +async def press_keys(req: dict): + keys = req.get("keys", "") + try: + import pyautogui + parts = [k.strip() for k in keys.split("+")] + pyautogui.hotkey(*parts) + return {"ok": True} + except Exception as e: + raise HTTPException(500, str(e)) + + +# ── Apps / Browser ───────────────────────────────────────────────────────── + +@app.post("/open") +async def open_target(req: dict): + target = req.get("target", "") + try: + if target.startswith("http://") or target.startswith("https://"): + webbrowser.open(target) + else: + os.startfile(target) + return {"ok": True} + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/search") +async def search_web(req: dict): + query = req.get("query", "") + url = f"https://www.google.com/search?q={quote(query)}" + webbrowser.open(url) + return {"ok": True, "url": url} + + +# ── Health ───────────────────────────────────────────────────────────────── + +@app.get("/health") +async def health(): + return {"status": "ok", "host": socket.gethostname()} + + +def _get_local_ip() -> str: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + return socket.gethostbyname(socket.gethostname()) + + +if __name__ == "__main__": + my_ip = _get_local_ip() + print(f"Hermes PC Executor läuft auf http://{my_ip}:{MY_PORT}") + uvicorn.run(app, host="0.0.0.0", port=MY_PORT) diff --git a/client/hermes-pc/install.bat b/client/hermes-pc/install.bat new file mode 100644 index 0000000..8770e35 --- /dev/null +++ b/client/hermes-pc/install.bat @@ -0,0 +1,8 @@ +@echo off +cd /d "%~dp0" +echo === Hermes PC Executor Setup === +python -m venv .venv +.venv\Scripts\pip install -r requirements.txt -q +echo. +echo Fertig. Starte mit start.bat +pause diff --git a/client/hermes-pc/requirements.txt b/client/hermes-pc/requirements.txt new file mode 100644 index 0000000..27bafe6 --- /dev/null +++ b/client/hermes-pc/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.111.0 +uvicorn>=0.30.0 +httpx>=0.27.0 +mss>=9.0.1 +Pillow>=10.0.0 +pyautogui>=0.9.54 diff --git a/client/hermes-pc/start.bat b/client/hermes-pc/start.bat new file mode 100644 index 0000000..1fd4163 --- /dev/null +++ b/client/hermes-pc/start.bat @@ -0,0 +1,9 @@ +@echo off +cd /d "%~dp0" +echo === Hermes PC Executor === +echo Port: 7777 +echo Hermes (AI Box) kann jetzt auf diesen PC zugreifen. +echo Fenster offen lassen solange Hermes PC-Zugriff braucht. +echo. +.venv\Scripts\python executor.py +pause diff --git a/client/hermes-voice/agent_client.py b/client/hermes-voice/agent_client.py new file mode 100644 index 0000000..61171fc --- /dev/null +++ b/client/hermes-voice/agent_client.py @@ -0,0 +1,31 @@ +"""Spricht mit dem Hermes Agent Daemon auf der AI Box.""" +import httpx + +TIMEOUT = 120 + + +def ask_agent(text: str, screenshot_b64: str | None, settings: dict) -> tuple[str, list]: + """Schickt Nachricht an den Daemon, gibt (response, tool_calls) zurück.""" + daemon_url = settings.get("agent_daemon_url", "http://192.168.178.151:8765") + try: + with httpx.Client(timeout=TIMEOUT) as c: + resp = c.post(f"{daemon_url}/chat", json={ + "message": text, + "screenshot": screenshot_b64, + }) + resp.raise_for_status() + data = resp.json() + return data.get("response", ""), data.get("tool_calls", []) + except httpx.TimeoutException: + return "Antwort hat zu lange gedauert.", [] + except Exception as e: + return f"Agent nicht erreichbar: {e}", [] + + +def get_agent_status(settings: dict) -> dict: + daemon_url = settings.get("agent_daemon_url", "http://192.168.178.151:8765") + try: + with httpx.Client(timeout=5) as c: + return c.get(f"{daemon_url}/status").json() + except Exception: + return {"alive": False} diff --git a/client/hermes-voice/main.py b/client/hermes-voice/main.py index 8b9dc65..26441f3 100644 --- a/client/hermes-voice/main.py +++ b/client/hermes-voice/main.py @@ -12,7 +12,7 @@ import config_manager as cfg from audio_input import start_recording, stop_recording, transcribe from audio_output import speak, stop_speaking, play_ding from screen_capture import capture_screen -from ai_client import ask +from agent_client import ask_agent, get_agent_status from hotkey_listener import HotkeyListener, KeyCapturer, str_to_display ctk.set_appearance_mode("dark") @@ -145,7 +145,12 @@ class HermesApp(ctk.CTk): ui_event("log_user", text) screenshot = (capture_screen() if self.settings["screenshot_enabled"] else None) - response = ask(text, screenshot, self.settings) + + ui_event("status", ("thinking", "💭 Denke…", "#3b82f6")) + response, tool_calls = ask_agent(text, screenshot, self.settings) + + for tc in tool_calls: + ui_event("log_tool", tc) ui_event("log_hermes", response) ui_event("status", ("speaking", "🔊 Spricht…", "#a855f7")) diff --git a/mcp/mcp_pc.py b/mcp/mcp_pc.py new file mode 100644 index 0000000..ebe0f9e --- /dev/null +++ b/mcp/mcp_pc.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Mission Control 2.0 — PC Control MCP Server. + +Stdio-MCP-Server. Läuft als Subprocess von Hermes Gateway (AI Box). +Proxied Befehle an den Hermes PC Executor (Windows PC, Port 7777). + +Env: PC_EXECUTOR_URL (default http://192.168.178.XXX:7777) +""" + +import os + +import httpx +from mcp.server.fastmcp import FastMCP + +PC_URL = os.environ.get("PC_EXECUTOR_URL", "").rstrip("/") + +mcp = FastMCP("hermes-pc-control") + + +def _pc(path: str, data: dict | None = None) -> dict: + if not PC_URL: + return {"error": "PC_EXECUTOR_URL nicht gesetzt. Setze die Env-Variable mit der IP des Windows PCs."} + try: + with httpx.Client(timeout=90) as c: + if data is None: + r = c.get(f"{PC_URL}{path}") + else: + r = c.post(f"{PC_URL}{path}", json=data) + r.raise_for_status() + return r.json() + except httpx.ConnectError: + return {"error": f"PC Executor nicht erreichbar ({PC_URL}). Läuft executor.py auf dem Windows PC?"} + except Exception as e: + return {"error": str(e)} + + +@mcp.tool() +def pc_shell(command: str) -> str: + """Führt einen PowerShell-Befehl auf dem Windows-PC des Nutzers aus. + Nutze dies um Dateien zu lesen/schreiben, Programme zu starten oder Systeminfos abzufragen. + Beispiele: 'Get-Date', 'ls C:\\Users', 'notepad.exe test.txt' + Gibt stdout, stderr und returncode zurück.""" + result = _pc("/shell", {"command": command}) + if "error" in result: + return f"FEHLER: {result['error']}" + parts = [] + if result.get("stdout"): + parts.append(f"stdout:\n{result['stdout']}") + if result.get("stderr"): + parts.append(f"stderr:\n{result['stderr']}") + parts.append(f"returncode: {result.get('returncode', '?')}") + return "\n".join(parts) + + +@mcp.tool() +def pc_screenshot() -> str: + """Macht einen Screenshot des Windows-PC-Bildschirms. + Nutze dies um zu sehen was der Nutzer gerade auf dem Bildschirm hat. + Gibt einen base64-kodierten JPEG-String zurück.""" + result = _pc("/screenshot", {}) + if "error" in result: + return f"FEHLER: {result['error']}" + b64 = result.get("screenshot", "") + if b64: + return f"[Screenshot aufgenommen, {len(b64)} Zeichen base64-JPEG]" + return "Kein Screenshot-Daten erhalten." + + +@mcp.tool() +def pc_type(text: str) -> str: + """Tippt Text in das aktive Fenster auf dem Windows-PC. + Der Nutzer muss das Zielfenster vorher aktiviert haben. + Nützlich um Text in Editoren, Terminals oder Formulare einzugeben.""" + result = _pc("/type", {"text": text}) + if "error" in result: + return f"FEHLER: {result['error']}" + return "Text getippt." if result.get("ok") else str(result) + + +@mcp.tool() +def pc_key(keys: str) -> str: + """Drückt eine Tastenkombination auf dem Windows-PC. + Format: Tasten mit '+' verbinden. Beispiele: 'ctrl+s', 'alt+F4', 'ctrl+shift+esc', 'win+d' + Nützlich um Tastenkürzel auszuführen.""" + result = _pc("/key", {"keys": keys}) + if "error" in result: + return f"FEHLER: {result['error']}" + return f"Tastenkombination '{keys}' gedrückt." if result.get("ok") else str(result) + + +@mcp.tool() +def pc_open(target: str) -> str: + """Öffnet eine URL im Browser oder eine Datei/Programm auf dem Windows-PC. + Für URLs: 'https://...' → öffnet im Standard-Browser. + Für Dateien/Programme: 'C:\\Pfad\\zur\\Datei.txt' oder 'notepad.exe'""" + result = _pc("/open", {"target": target}) + if "error" in result: + return f"FEHLER: {result['error']}" + return f"'{target}' geöffnet." if result.get("ok") else str(result) + + +@mcp.tool() +def pc_status() -> str: + """Prüft ob der Windows-PC Executor erreichbar ist und gibt Hostname zurück. + Rufe dies auf um zu prüfen ob PC-Steuerung möglich ist, bevor du andere pc_* Tools nutzt.""" + result = _pc("/health") + if "error" in result: + return f"PC Executor NICHT erreichbar: {result['error']}" + return f"PC Executor erreichbar. Hostname: {result.get('host', '?')}, Status: {result.get('status', '?')}" + + +if __name__ == "__main__": + mcp.run()