diff --git a/mcp-server/__pycache__/server.cpython-313.pyc b/mcp-server/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000..c0bea19 Binary files /dev/null and b/mcp-server/__pycache__/server.cpython-313.pyc differ diff --git a/mcp-server/server.py b/mcp-server/server.py new file mode 100644 index 0000000..0a45881 --- /dev/null +++ b/mcp-server/server.py @@ -0,0 +1,244 @@ +""" +Mortdecai Gateway MCP Server. + +Wraps the gateway HTTP API as MCP tools so Claude can operate +Mortdecai natively. All game operations go through the gateway — +this server never touches Minecraft directly. +""" + +import json +import subprocess +from pathlib import Path + +import httpx +from mcp.server.fastmcp import FastMCP + +GATEWAY_URL = "http://localhost:8500" +SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" + +mcp = FastMCP("mortdecai-gateway") + + +async def _get(path: str) -> dict: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{GATEWAY_URL}{path}") + resp.raise_for_status() + return resp.json() + + +async def _post(path: str, body: dict | None = None) -> dict: + async with httpx.AsyncClient(timeout=90) as client: + resp = await client.post(f"{GATEWAY_URL}{path}", json=body or {}) + resp.raise_for_status() + return resp.json() + + +async def _patch(path: str, body: dict | None = None) -> dict: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.patch(f"{GATEWAY_URL}{path}", json=body or {}) + resp.raise_for_status() + return resp.json() + + +# --- Gateway lifecycle --- + + +@mcp.tool() +def gateway_start() -> str: + """Start the Mortdecai gateway if not running.""" + result = subprocess.run( + ["bash", str(SCRIPTS_DIR / "start-gateway.sh")], + capture_output=True, text=True, timeout=30, + ) + return result.stdout + result.stderr + + +@mcp.tool() +def gateway_stop() -> str: + """Stop the Mortdecai gateway.""" + result = subprocess.run( + ["bash", str(SCRIPTS_DIR / "stop-gateway.sh")], + capture_output=True, text=True, timeout=10, + ) + return result.stdout + result.stderr + + +@mcp.tool() +def gateway_restart() -> str: + """Restart the Mortdecai gateway (stop + start).""" + stop = subprocess.run( + ["bash", str(SCRIPTS_DIR / "stop-gateway.sh")], + capture_output=True, text=True, timeout=10, + ) + import time; time.sleep(2) + start = subprocess.run( + ["bash", str(SCRIPTS_DIR / "start-gateway.sh")], + capture_output=True, text=True, timeout=30, + ) + return stop.stdout + start.stdout + start.stderr + + +@mcp.tool() +async def gateway_status() -> str: + """Get full gateway status: providers, sessions, oracle state.""" + try: + data = await _get("/v2/status") + return json.dumps(data, indent=2) + except httpx.ConnectError: + return "Gateway is DOWN — not reachable on port 8500" + except Exception as e: + return f"Error: {e}" + + +@mcp.tool() +async def gateway_health() -> str: + """Quick health check — is the gateway alive?""" + try: + data = await _get("/v2/health") + return json.dumps(data, indent=2) + except httpx.ConnectError: + return "DOWN" + except Exception as e: + return f"Error: {e}" + + +# --- Player commands (through gateway) --- + + +@mcp.tool() +async def gateway_command( + player: str, + text: str, + mode: str = "sudo", + server: str = "dev", +) -> str: + """Send a command through the gateway as if a player typed it in-game. + + Args: + player: Minecraft player name + text: The command text (e.g. "give me a diamond") + mode: Command mode — sudo, pray, ask, or raw + server: Server target — dev or prod + """ + try: + data = await _post("/v2/quick", { + "player": player, + "text": text, + "server": server, + "command_type": mode, + }) + return json.dumps(data, indent=2) + except Exception as e: + return f"Error: {e}" + + +# --- Brain management (hot-swap providers) --- + + +@mcp.tool() +async def gateway_brain_set( + role: str, + provider: str, + model: str, +) -> str: + """Hot-swap the AI provider and model for a gateway role. + + Args: + role: Agent role — eye, hand, voice, opus, architect, orchestrator + provider: Provider name — anthropic, codex, openai, ollama, regex + model: Model identifier (e.g. "gpt-5.1-codex-mini", "claude-opus-4-20250514") + """ + try: + data = await _patch(f"/v2/brain/{role}", { + "provider": provider, + "model": model, + }) + return json.dumps(data, indent=2) + except Exception as e: + return f"Error: {e}" + + +@mcp.tool() +async def gateway_brain_save(role: str) -> str: + """Persist a brain's current live override to agents.yaml on disk. + + Args: + role: Agent role to save + """ + try: + data = await _post(f"/v2/brain/{role}/save") + return json.dumps(data, indent=2) + except Exception as e: + return f"Error: {e}" + + +@mcp.tool() +async def gateway_brain_reload(role: str) -> str: + """Clear in-memory override, reload brain config from agents.yaml. + + Args: + role: Agent role to reload + """ + try: + data = await _post(f"/v2/brain/{role}/reload") + return json.dumps(data, indent=2) + except Exception as e: + return f"Error: {e}" + + +# --- Session management --- + + +@mcp.tool() +async def gateway_sessions_clear(player: str, mode: str = "") -> str: + """Clear sessions for a player. Optionally filter by mode. + + Args: + player: Player name + mode: Optional mode filter (sudo, pray, ask, raw). Empty = all modes. + """ + try: + url = f"/v2/sessions/clear/{player}" + if mode: + url += f"?mode={mode}" + data = await _post(url) + return json.dumps(data, indent=2) + except Exception as e: + return f"Error: {e}" + + +@mcp.tool() +async def gateway_sessions_reset() -> str: + """Clear ALL sessions for ALL players. Use with caution.""" + try: + data = await _post("/v2/sessions/reset") + return json.dumps(data, indent=2) + except Exception as e: + return f"Error: {e}" + + +# --- Logs --- + + +@mcp.tool() +def gateway_logs(lines: int = 50) -> str: + """Read recent gateway log output. + + Args: + lines: Number of lines to read from the end (default 50) + """ + log_path = Path("/tmp/mortdecai-gateway.log") + if not log_path.exists(): + return "No gateway log file found" + try: + result = subprocess.run( + ["tail", f"-{lines}", str(log_path)], + capture_output=True, text=True, timeout=5, + ) + return result.stdout or "Log file is empty" + except Exception as e: + return f"Error reading logs: {e}" + + +if __name__ == "__main__": + mcp.run(transport="stdio")