"""Lazy proxy for android_mcp.py: 3 tools instead of 65 schemas in every request. The real server (65 tools) is only spawned on the first phone_* call. ponytail: one shared child session, no reconnect on crash (restart dsh); fine for a phone-local agent. """ import asyncio import json import sys from pathlib import Path from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from mcp.server.fastmcp import FastMCP REAL = Path.home() / "pixelphone/event_bus/android_mcp.py" mcp = FastMCP("android_phone") _session = None _ready = asyncio.Event() async def _owner(): # the stdio/session contexts must be entered and exited by the SAME task (anyio cancel scopes), # so one long-lived task owns them instead of whichever request came first. global _session params = StdioServerParameters(command=sys.executable, args=[str(REAL)], cwd=str(REAL.parent)) async with stdio_client(params) as (r, w), ClientSession(r, w) as s: await s.initialize() _session = s _ready.set() await asyncio.Event().wait() _task = None async def real() -> ClientSession: global _task if _task is None: _task = asyncio.create_task(_owner()) await asyncio.wait_for(_ready.wait(), 90) return _session def _text(res) -> str: return "\n".join(getattr(c, "text", str(c)) for c in res.content) @mcp.tool() async def phone_list(keyword: str = "") -> str: """List Android-phone tools (name + first description line). Filter by keyword; call phone_describe(name) before phone_call.""" tools = (await (await real()).list_tools()).tools kw = keyword.lower() rows = [f"{t.name}: {(t.description or '').strip().splitlines()[0] if t.description else ''}" for t in tools if kw in t.name.lower() or kw in (t.description or "").lower()] return "\n".join(rows) or "no match" @mcp.tool() async def phone_describe(name: str) -> str: """Full description and JSON input schema of one phone tool.""" for t in (await (await real()).list_tools()).tools: if t.name == name: return f"{t.description}\n\ninputSchema: {json.dumps(t.inputSchema, ensure_ascii=False)}" return f"unknown tool: {name} (use phone_list)" @mcp.tool() async def phone_call(name: str, arguments: dict | None = None) -> str: """Call one phone tool by name with a JSON arguments object.""" res = await (await real()).call_tool(name, arguments or {}) out = _text(res) return f"[tool error] {out}" if res.isError else out if __name__ == "__main__": mcp.run()