feat: MCP server wrapping gateway HTTP API (12 tools)
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user