""" 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")