133 lines
5.5 KiB
Python
Executable File
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())
|