From 44c13f229cadc056af0241c79c75a6fcf202508d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 28 Mar 2026 18:59:48 -0400 Subject: [PATCH] feat: MCP server wrapping gateway HTTP API (12 tools) --- mcp-server/__pycache__/server.cpython-313.pyc | Bin 0 -> 11225 bytes mcp-server/server.py | 244 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 mcp-server/__pycache__/server.cpython-313.pyc create mode 100644 mcp-server/server.py diff --git a/mcp-server/__pycache__/server.cpython-313.pyc b/mcp-server/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0bea190e27b08ced8aa67d5ac8b6927e077a224 GIT binary patch literal 11225 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 bxOZK0e)r6`&Rm&UIP;m*mKTZC#L@iUCm}Tc literal 0 HcmV?d00001 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")