Files
mortdecai-cli/mcp-server/server.py
T
2026-03-28 18:59:48 -04:00

245 lines
6.3 KiB
Python

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