Files
timmy-home/scripts/evennia/evennia_mcp_server.py
2026-03-28 13:33:26 -04:00

133 lines
5.5 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
import telnetlib
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from evennia_tools.telemetry import append_event, excerpt
HOST = "127.0.0.1"
PORT = 4000
TIMMY_PASSWORD = os.environ.get("TIMMY_EVENNIA_TIMMY_PASSWORD", "timmy-world-dev")
SESSIONS: dict[str, telnetlib.Telnet] = {}
STATE_DIR = Path.home() / ".timmy" / "training-data" / "evennia"
STATE_FILE = STATE_DIR / "mcp_state.json"
app = Server("evennia")
def _load_bound_session_id() -> str:
try:
if STATE_FILE.exists():
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
return (data.get("bound_session_id") or "unbound").strip() or "unbound"
except Exception:
pass
return "unbound"
def _save_bound_session_id(session_id: str) -> str:
STATE_DIR.mkdir(parents=True, exist_ok=True)
session_id = (session_id or "unbound").strip() or "unbound"
STATE_FILE.write_text(json.dumps({"bound_session_id": session_id}, indent=2), encoding="utf-8")
return session_id
def _read(tn: telnetlib.Telnet, delay: float = 0.5) -> str:
time.sleep(delay)
return tn.read_very_eager().decode("utf-8", "ignore")
def _connect(name: str = "timmy", username: str = "Timmy", password: str | None = None) -> dict:
if name in SESSIONS:
return {"connected": True, "name": name, "output": ""}
tn = telnetlib.Telnet(HOST, PORT, timeout=10)
banner = _read(tn, 1.0)
tn.write(f"connect {username} {password or TIMMY_PASSWORD}\n".encode())
out = _read(tn, 1.0)
SESSIONS[name] = tn
append_event(_load_bound_session_id(), {"event": "connect", "actor": username, "output": excerpt(banner + "\n" + out)})
return {"connected": True, "name": name, "output": banner + "\n" + out}
def _observe(name: str = "timmy") -> dict:
if name not in SESSIONS:
_connect(name=name)
tn = SESSIONS[name]
return {"name": name, "output": _read(tn, 0.2)}
def _command(command: str, name: str = "timmy", wait_ms: int = 500) -> dict:
if name not in SESSIONS:
_connect(name=name)
tn = SESSIONS[name]
tn.write((command + "\n").encode())
out = _read(tn, max(0.1, wait_ms / 1000.0))
append_event(_load_bound_session_id(), {"event": "command", "actor": name, "command": command, "output": excerpt(out, 1000)})
return {"name": name, "command": command, "output": out}
def _disconnect(name: str = "timmy") -> dict:
tn = SESSIONS.pop(name, None)
if tn:
try:
tn.close()
except Exception:
pass
append_event(_load_bound_session_id(), {"event": "disconnect", "actor": name})
return {"disconnected": bool(tn), "name": name}
@app.list_tools()
async def list_tools():
return [
Tool(name="bind_session", description="Bind a Hermes session id to Evennia telemetry logs.", inputSchema={"type": "object", "properties": {"session_id": {"type": "string"}}, "required": ["session_id"]}),
Tool(name="status", description="Show Evennia MCP/telnet control status.", inputSchema={"type": "object", "properties": {}, "required": []}),
Tool(name="connect", description="Connect Timmy to the local Evennia telnet server as a real in-world account.", inputSchema={"type": "object", "properties": {"name": {"type": "string"}, "username": {"type": "string"}, "password": {"type": "string"}}, "required": []}),
Tool(name="observe", description="Read pending text output from Timmy's Evennia connection.", inputSchema={"type": "object", "properties": {"name": {"type": "string"}}, "required": []}),
Tool(name="command", description="Send one Evennia command line as Timmy and return the resulting text output.", inputSchema={"type": "object", "properties": {"command": {"type": "string"}, "name": {"type": "string"}, "wait_ms": {"type": "integer", "default": 500}}, "required": ["command"]}),
Tool(name="disconnect", description="Close Timmy's Evennia telnet control session.", inputSchema={"type": "object", "properties": {"name": {"type": "string"}}, "required": []}),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict):
arguments = arguments or {}
if name == "bind_session":
bound = _save_bound_session_id(arguments.get("session_id", "unbound"))
result = {"bound_session_id": bound}
elif name == "status":
result = {"connected_sessions": sorted(SESSIONS.keys()), "bound_session_id": _load_bound_session_id()}
elif name == "connect":
result = _connect(arguments.get("name", "timmy"), arguments.get("username", "Timmy"), arguments.get("password"))
elif name == "observe":
result = _observe(arguments.get("name", "timmy"))
elif name == "command":
result = _command(arguments.get("command", ""), arguments.get("name", "timmy"), arguments.get("wait_ms", 500))
elif name == "disconnect":
result = _disconnect(arguments.get("name", "timmy"))
else:
result = {"error": f"Unknown tool: {name}"}
return [TextContent(type="text", text=json.dumps(result, indent=2))]
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())