debug-control-plane 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- debug_control_plane/__init__.py +7 -0
- debug_control_plane/device_discovery/__init__.py +82 -0
- debug_control_plane/device_discovery/device_candidates.py +326 -0
- debug_control_plane/device_discovery/device_pool.py +369 -0
- debug_control_plane/device_discovery/discovery/__init__.py +14 -0
- debug_control_plane/device_discovery/discovery/cross_identify.py +396 -0
- debug_control_plane/device_discovery/discovery/lan_scan.py +183 -0
- debug_control_plane/device_discovery/discovery/manual_registry.py +245 -0
- debug_control_plane/device_discovery/discovery/usb_identity.py +228 -0
- debug_control_plane/device_discovery/discovery/vpn_immune.py +103 -0
- debug_control_plane/device_discovery/endpoint.py +317 -0
- debug_control_plane/device_discovery/protocol.py +230 -0
- debug_control_plane/mcp_plane/__init__.py +47 -0
- debug_control_plane/mcp_plane/bridge_client.py +525 -0
- debug_control_plane/mcp_plane/capability_mirror.py +642 -0
- debug_control_plane/mcp_plane/semantic_provider.py +57 -0
- debug_control_plane/mcp_plane/server.py +874 -0
- debug_control_plane-0.1.0.dist-info/METADATA +98 -0
- debug_control_plane-0.1.0.dist-info/RECORD +23 -0
- debug_control_plane-0.1.0.dist-info/WHEEL +5 -0
- debug_control_plane-0.1.0.dist-info/entry_points.txt +2 -0
- debug_control_plane-0.1.0.dist-info/licenses/LICENSE +21 -0
- debug_control_plane-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"""MCP stdio server — assembly point for the debug-bridge adapter layer.
|
|
2
|
+
|
|
3
|
+
(R020-BF011)
|
|
4
|
+
|
|
5
|
+
This module is the **single SDK touchpoint** (design §3.4 / §4.4): it wires
|
|
6
|
+
BF001/BF005/BF008/BF009/BF010 onto the MCP Python SDK and serves the result
|
|
7
|
+
over stdio. The lifecycle follows the AI session (``run_stdio`` blocks until
|
|
8
|
+
the client closes stdin; the process exits, it is NOT a daemon — design S7).
|
|
9
|
+
|
|
10
|
+
mcp SDK 1.28.1 spike conclusions (BF011.5, see evidence):
|
|
11
|
+
|
|
12
|
+
* **Why the low-level ``Server`` (not ``FastMCP``):** FastMCP's
|
|
13
|
+
``add_tool(fn)`` derives ``inputSchema`` from the function's type hints.
|
|
14
|
+
Our tools come from :class:`CapabilityMirror.build_tools`, which already
|
|
15
|
+
carries a hand-built JSON-Schema dict (BF009 ``ToolSpec.input_schema``).
|
|
16
|
+
The low-level ``Server`` lets ``@server.list_tools()`` return
|
|
17
|
+
``mcp.types.Tool(name, description, inputSchema=...)`` directly — a 1:1
|
|
18
|
+
mapping of ToolSpec → Tool with zero hint-derivation friction. It also
|
|
19
|
+
re-runs the handler on **every** ``tools/list`` and refreshes
|
|
20
|
+
``_tool_cache`` from the return value (verified in source) — which is
|
|
21
|
+
exactly design §5.5 ① "snapshot rebuild, recompute the whole table each
|
|
22
|
+
time" without us having to manage ``add_tool``/``remove_tool``.
|
|
23
|
+
* **``capabilities.tools.listChanged=true``** is declared automatically
|
|
24
|
+
once a ``@server.list_tools()`` handler is registered *and*
|
|
25
|
+
``NotificationOptions(tools_changed=True)`` is passed to
|
|
26
|
+
``create_initialization_options`` (verified — ``get_capabilities`` maps
|
|
27
|
+
handler presence + notification option to the capability flag).
|
|
28
|
+
* **``notifications/tools/list_changed``** is sent via
|
|
29
|
+
``request_ctx.get().session.send_tool_list_changed()``. The session is
|
|
30
|
+
only reachable inside a request handler (it is a local var of
|
|
31
|
+
``Server.run``), so a background polling task cannot send it. Design
|
|
32
|
+
§5.5 ②③ "list_changed + polling" is therefore realized as: after any
|
|
33
|
+
tool call that may mutate the manifest (``discover_devices`` /
|
|
34
|
+
``register_device`` / ``list_capabilities``), the handler — still inside
|
|
35
|
+
the request context — calls ``mirror.refresh`` for the affected device
|
|
36
|
+
and, on change, emits the notification. The **main consistency path**
|
|
37
|
+
remains §5.5 ① (every ``tools/list`` recomputes from current /hello),
|
|
38
|
+
so list_changed is a SHOULD-tier convenience, not a consistency
|
|
39
|
+
requirement (design §5.5 D-1).
|
|
40
|
+
* **Error mapping (§5.5 ④):** ``raise McpError(ErrorData(...))`` from
|
|
41
|
+
``call_tool`` is converted by the SDK (``raise_exceptions=False``) into
|
|
42
|
+
a ``CallToolResult(isError=True, content=[TextContent(message)])``.
|
|
43
|
+
This is the MCP-spec "Tool Execution Error" path and what we use for
|
|
44
|
+
every :class:`BridgeError` subclass (DeviceUnreachable / DeviceStale /
|
|
45
|
+
DeviceHttpError), so the AI gets a structured, actionable error instead
|
|
46
|
+
of the server crashing.
|
|
47
|
+
|
|
48
|
+
Refs:
|
|
49
|
+
- tasks: .dev-flow/R020/mcp-bridge-device-discovery-tasks.md BF011
|
|
50
|
+
- design: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
|
|
51
|
+
§3.4 McpServer / §4.4 Python API / §5.1 sequence / §5.2 degrade
|
|
52
|
+
/ §5.5 tool-table consistency
|
|
53
|
+
- test: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-test.md §2.1
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import logging
|
|
59
|
+
import time
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
import anyio
|
|
64
|
+
|
|
65
|
+
# --- mcp SDK (spike: low-level Server, see module docstring) ----------------
|
|
66
|
+
from mcp import types
|
|
67
|
+
from mcp.server import NotificationOptions
|
|
68
|
+
from mcp.server.lowlevel.server import Server, request_ctx
|
|
69
|
+
from mcp.server.stdio import stdio_server
|
|
70
|
+
from mcp.shared.exceptions import McpError
|
|
71
|
+
|
|
72
|
+
from debug_control_plane.device_discovery.device_pool import DevicePool, DeviceRecord
|
|
73
|
+
from debug_control_plane.device_discovery.discovery.cross_identify import CrossIdentify
|
|
74
|
+
from debug_control_plane.device_discovery.discovery.lan_scan import LanScan
|
|
75
|
+
from debug_control_plane.device_discovery.discovery.manual_registry import ManualRegistry
|
|
76
|
+
from debug_control_plane.device_discovery.discovery.usb_identity import UsbIdentity
|
|
77
|
+
from debug_control_plane.device_discovery.discovery.vpn_immune import VpnImmune
|
|
78
|
+
|
|
79
|
+
# --- BF007 包内相对 + 跨包 device_discovery -------------------------------
|
|
80
|
+
from .bridge_client import (
|
|
81
|
+
BridgeClient,
|
|
82
|
+
BridgeError,
|
|
83
|
+
DeviceHttpError,
|
|
84
|
+
DeviceStale,
|
|
85
|
+
DeviceUnreachable,
|
|
86
|
+
)
|
|
87
|
+
from .capability_mirror import CapabilityMirror, ToolSpec
|
|
88
|
+
from .semantic_provider import SemanticProvider
|
|
89
|
+
|
|
90
|
+
# ★ BF008-010 (Contract §0.1 边界 1 收尾 + 方案 X): capability-specific semantic
|
|
91
|
+
# sugar 已迁业务侧(强耦合产品 protocol,属业务知识)。平面 server 零业务依赖
|
|
92
|
+
# (硬约束②),改 __init__ providers + tool_handlers 参数注入。业务装配(console-
|
|
93
|
+
# script 装 SemanticProvider 子类 + 业务 async handler + 启动 McpServer)归
|
|
94
|
+
# R021-CLEANUP;main() 保持裸 server(providers=None, tool_handlers=None)。
|
|
95
|
+
|
|
96
|
+
logger = logging.getLogger("mcp_debug_bridge.server")
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Constants
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
#: Default listen port of the phone's R19 debug plane.
|
|
103
|
+
_DEFAULT_PORT: int = 18080
|
|
104
|
+
|
|
105
|
+
#: How many events to drain from the SSE stream per ``subscribe_events`` call.
|
|
106
|
+
#:
|
|
107
|
+
#: MCP stdio is request/response — a single ``tools/call`` must return. We
|
|
108
|
+
#: drain a bounded batch of buffered events and return them; the AI can call
|
|
109
|
+
#: again for the next batch. (design §4.2.2 subscribe_events — SSE mapped to
|
|
110
|
+
#: structured DebugEvent; long-lived streaming is not a fit for the stdio
|
|
111
|
+
#: request/response model.)
|
|
112
|
+
_SUBSCRIBE_EVENT_BATCH: int = 16
|
|
113
|
+
|
|
114
|
+
#: Cap of wall-clock seconds for one ``subscribe_events`` batch. Keeps the
|
|
115
|
+
#: tool responsive when the stream is quiet (returns whatever arrived).
|
|
116
|
+
_SUBSCRIBE_EVENT_TIMEOUT_S: float = 1.0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Tool-spec → mcp Tool adapter (the only place that imports mcp.types.Tool)
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _tool_spec_to_mcp(spec: ToolSpec) -> types.Tool:
|
|
125
|
+
"""Map BF009's SDK-agnostic ToolSpec onto an ``mcp.types.Tool``.
|
|
126
|
+
|
|
127
|
+
The fields are 1:1 (``name`` / ``description`` / ``inputSchema``). This
|
|
128
|
+
is the seam BF011 owns: BF009 stays SDK-free and testable without a live
|
|
129
|
+
server; BF011 is the single module that knows about the mcp SDK's Tool
|
|
130
|
+
shape (layered design, see capability_mirror.py docstring).
|
|
131
|
+
"""
|
|
132
|
+
return types.Tool(
|
|
133
|
+
name=spec.name,
|
|
134
|
+
description=spec.description,
|
|
135
|
+
inputSchema=spec.input_schema,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# BridgeError → McpError translation (design §5.5 ④ error floor)
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _bridge_error_to_mcp(exc: BridgeError | DeviceUnreachable) -> McpError:
|
|
145
|
+
"""Translate a :class:`BridgeError` into an MCP ``isError`` result.
|
|
146
|
+
|
|
147
|
+
Keeps the server alive (no crash) and gives the AI an actionable hint:
|
|
148
|
+
|
|
149
|
+
* :class:`DeviceUnreachable` → "device {id} not found; run
|
|
150
|
+
discover_devices or register_device first" (analysis L1/L2/L3).
|
|
151
|
+
* :class:`DeviceStale` → "device {id} IP TTL expired; re-discover"
|
|
152
|
+
(design §5.3).
|
|
153
|
+
* :class:`DeviceHttpError` → verbatim status_code + body (so 409
|
|
154
|
+
``real_controller_active`` propagates with its hint).
|
|
155
|
+
* bare :class:`BridgeError` → generic transport failure.
|
|
156
|
+
|
|
157
|
+
The MCP ``code`` mirrors JSON-RPC's ``-32602`` (invalid params) semantics
|
|
158
|
+
so AI clients that branch on the code treat it as a retryable user error
|
|
159
|
+
rather than a fatal server fault.
|
|
160
|
+
"""
|
|
161
|
+
if isinstance(exc, DeviceHttpError):
|
|
162
|
+
hint = ""
|
|
163
|
+
# 409 real_controller_active: the phone's contract says the real pad
|
|
164
|
+
# wins; surface that as an actionable hint (analysis fault injection).
|
|
165
|
+
if exc.status_code == 409:
|
|
166
|
+
hint = " (real controller is active — disconnect it or let it win)"
|
|
167
|
+
message = (
|
|
168
|
+
f"device HTTP error: status={exc.status_code} "
|
|
169
|
+
f"body={exc.body!r}{hint}"
|
|
170
|
+
)
|
|
171
|
+
elif isinstance(exc, DeviceUnreachable):
|
|
172
|
+
message = (
|
|
173
|
+
f"device unreachable: {exc}"
|
|
174
|
+
" — run discover_devices (or register_device with a known host)"
|
|
175
|
+
)
|
|
176
|
+
elif isinstance(exc, DeviceStale):
|
|
177
|
+
message = (
|
|
178
|
+
f"device stale: {exc}"
|
|
179
|
+
" — IP TTL expired; run discover_devices to re-locate it"
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
message = f"bridge error: {exc}"
|
|
183
|
+
|
|
184
|
+
return McpError(
|
|
185
|
+
types.ErrorData(code=-32602, message=message, data=None)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# McpServer — assembly + handler registration
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class McpServer:
|
|
195
|
+
"""MCP stdio server holding the assembled adapter layer (design §4.4).
|
|
196
|
+
|
|
197
|
+
Owns three concerns, each delegated to its BF module:
|
|
198
|
+
|
|
199
|
+
* **Manifest** (what tools exist): :class:`CapabilityMirror` — queried
|
|
200
|
+
on every ``tools/list`` (design §5.5 ①).
|
|
201
|
+
* **Dispatch** (executing a tool by name): a name → async-handler table
|
|
202
|
+
wired here; each handler delegates to BridgeClient / DevicePool /
|
|
203
|
+
injected business handlers and maps BridgeError → McpError.
|
|
204
|
+
* **Lifecycle** (stdio blocking on the AI session): ``run_stdio``
|
|
205
|
+
enters ``stdio_server`` + ``Server.run``; the process exits when the
|
|
206
|
+
client disconnects stdin (NOT a daemon — design S7).
|
|
207
|
+
|
|
208
|
+
Construction is side-effect-free; the SDK handlers are registered in
|
|
209
|
+
:meth:`_register_handlers` (called from :meth:`run_stdio` so the
|
|
210
|
+
:class:`Server` instance is created lazily, keeping tests cheap).
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
mirror: CapabilityMirror,
|
|
216
|
+
client: BridgeClient,
|
|
217
|
+
pool: DevicePool,
|
|
218
|
+
*,
|
|
219
|
+
server_name: str = "mcp-debug-bridge",
|
|
220
|
+
server_version: str = "0.1.0",
|
|
221
|
+
providers: list[SemanticProvider] | None = None,
|
|
222
|
+
tool_handlers: dict[str, Any] | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Assemble the adapter layer with optional semantic-sugar injection.
|
|
225
|
+
|
|
226
|
+
★ BF010 (Contract §0.1 边界 1 收尾 + 方案 X): ``providers`` +
|
|
227
|
+
``tool_handlers`` are the **injection interface** for capability-
|
|
228
|
+
specific semantic sugar (per-product). The server itself is
|
|
229
|
+
capability-agnostic — it knows nothing about any product's
|
|
230
|
+
capability protocol. The caller (console-script entry, R021-CLEANUP)
|
|
231
|
+
wires the product's ``SemanticProvider`` subclass + async handler
|
|
232
|
+
map here.
|
|
233
|
+
|
|
234
|
+
``providers`` are registered on ``mirror`` once at construction so
|
|
235
|
+
``build_tools()`` sees them from the very first tools/list (design
|
|
236
|
+
§3.4 SRV→MIR→ST arrow; idempotent on CapabilityMirror's side).
|
|
237
|
+
|
|
238
|
+
``tool_handlers`` is the business async handler map (tool name →
|
|
239
|
+
``async (args) -> result``). ``_build_dispatch`` merges it with the
|
|
240
|
+
meta + device-management handlers; main() keeps a bare server
|
|
241
|
+
(``tool_handlers=None``) so the control_plane repo stays zero-
|
|
242
|
+
business-dependency (硬约束②). 补 design 空白:SemanticProvider
|
|
243
|
+
Protocol 只暴露 manifest (matches + build_tools), 不含 handler
|
|
244
|
+
注入接口(BF002 YAGNI),故 handler 走独立参数。
|
|
245
|
+
"""
|
|
246
|
+
self._mirror = mirror
|
|
247
|
+
self._client = client
|
|
248
|
+
self._pool = pool
|
|
249
|
+
self._server_name = server_name
|
|
250
|
+
self._server_version = server_version
|
|
251
|
+
# ★ BF010: 外部注入 providers (server 零业务硬编码).
|
|
252
|
+
for p in (providers or []):
|
|
253
|
+
self._mirror.register_provider(p)
|
|
254
|
+
# ★ 补 design 空白: 外部注入 tool_handlers (业务 async handler).
|
|
255
|
+
self._tool_handlers: dict[str, Any] = (
|
|
256
|
+
dict(tool_handlers) if tool_handlers else {}
|
|
257
|
+
)
|
|
258
|
+
# Lazy-built low-level Server (created in run_stdio so __init__ stays
|
|
259
|
+
# side-effect-free and unit tests can construct McpServer cheaply).
|
|
260
|
+
self._app: Server | None = None
|
|
261
|
+
self._dispatch: dict[str, Any] = {}
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# Dispatch table — built once, called per tools/call
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def _build_dispatch(self) -> dict[str, Any]:
|
|
268
|
+
"""Return the name → async-handler map (design §4.2 tool routing).
|
|
269
|
+
|
|
270
|
+
Each handler takes the raw ``arguments`` dict and returns an MCP
|
|
271
|
+
result (list[Content] | CallToolResult | dict). They wrap the
|
|
272
|
+
synchronous BF modules in ``anyio.to_thread.run_sync`` so the stdio
|
|
273
|
+
event loop is never blocked by an HTTP probe (BridgeClient is sync;
|
|
274
|
+
spike confirmed the SDK runs handlers on the event loop directly).
|
|
275
|
+
"""
|
|
276
|
+
client = self._client
|
|
277
|
+
pool = self._pool
|
|
278
|
+
mirror = self._mirror
|
|
279
|
+
|
|
280
|
+
async def _run(sync_fn, *args):
|
|
281
|
+
"""Run a sync BF call off the event loop + map errors to MCP.
|
|
282
|
+
|
|
283
|
+
AD-B9 后 DeviceUnreachable 直继承 Exception(脱离 BridgeError),
|
|
284
|
+
需独立 catch 调 ``_bridge_error_to_mcp``(该函数内已有
|
|
285
|
+
``isinstance(exc, DeviceUnreachable)`` 分支,运行时正常翻译)。
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
return await anyio.to_thread.run_sync(sync_fn, *args)
|
|
289
|
+
except (BridgeError, DeviceUnreachable) as exc:
|
|
290
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
291
|
+
|
|
292
|
+
# ★ BF008-010 (Contract §0.1 边界 1 收尾): capability-specific
|
|
293
|
+
# semantic-sugar handlers 整段删 — 业务 async handler 改由 __init__
|
|
294
|
+
# tool_handlers 参数注入,_build_dispatch 合并 **self._tool_handlers
|
|
295
|
+
# (见下方 return)。平面 server 零业务知识(硬约束②),业务装配归
|
|
296
|
+
# R021-CLEANUP。
|
|
297
|
+
|
|
298
|
+
# -- meta tools (BF008 BridgeClient forward) ---------------------
|
|
299
|
+
async def h_invoke_command(args):
|
|
300
|
+
# invoke_command is the unknown-capability fallback (design §4.2.2
|
|
301
|
+
# / AC10). BF009's input schema carries capability_id +
|
|
302
|
+
# command_path segments + args; the phone's REST contract keys on
|
|
303
|
+
# method+path, so we forward command_path as the path and pass
|
|
304
|
+
# the body verbatim. method defaults to POST (R19 commands are
|
|
305
|
+
# all POST per the debug-capability declarations); a future
|
|
306
|
+
# schema field could override it. capability_id is reserved for
|
|
307
|
+
# routing/audit (the phone's path already encodes it) so it's
|
|
308
|
+
# validated for presence by the schema but not forwarded.
|
|
309
|
+
return await _run(
|
|
310
|
+
client.invoke,
|
|
311
|
+
args["device_id"], "POST",
|
|
312
|
+
list(args.get("command_path", [])),
|
|
313
|
+
args.get("args"),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
async def h_read_resource(args):
|
|
317
|
+
return await _run(
|
|
318
|
+
client.read,
|
|
319
|
+
args["device_id"], list(args.get("resource_path", [])),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
async def h_list_capabilities(args):
|
|
323
|
+
device_id = args["device_id"]
|
|
324
|
+
# Probe /hello + drive list_changed change-detection (§5.5 ②③).
|
|
325
|
+
# ``mirror.refresh`` is **degrade-only**: it catches every
|
|
326
|
+
# BridgeError subclass itself (clearing the cache, returning the
|
|
327
|
+
# had-cache signal) and never re-raises — so this probe does not
|
|
328
|
+
# throw on an unreachable device; it just yields an empty schema
|
|
329
|
+
# list. We capture refresh's change signal here (one HTTP probe)
|
|
330
|
+
# and feed it straight into ``_emit_list_changed`` below, rather
|
|
331
|
+
# than re-probing via ``_maybe_emit_list_changed_for``.
|
|
332
|
+
def _probe():
|
|
333
|
+
changed = mirror.refresh(device_id)
|
|
334
|
+
return mirror.schemas(device_id), changed
|
|
335
|
+
|
|
336
|
+
schemas, changed = await anyio.to_thread.run_sync(_probe)
|
|
337
|
+
|
|
338
|
+
# Drive list_changed from this request's context (session is
|
|
339
|
+
# reachable here — see module docstring spike note). Best-effort:
|
|
340
|
+
# §5.5 ① (every tools/list recomputes) is the main consistency
|
|
341
|
+
# path; this notification is the SHOULD-tier convenience.
|
|
342
|
+
if changed:
|
|
343
|
+
await self._emit_list_changed()
|
|
344
|
+
|
|
345
|
+
return _schemas_to_jsonable(schemas)
|
|
346
|
+
|
|
347
|
+
async def h_get_state(args):
|
|
348
|
+
return await _run(client.read, args["device_id"], ["state"])
|
|
349
|
+
|
|
350
|
+
async def h_subscribe_events(args):
|
|
351
|
+
device_id = args["device_id"]
|
|
352
|
+
event_types = args.get("event_types")
|
|
353
|
+
|
|
354
|
+
def _drain():
|
|
355
|
+
events = []
|
|
356
|
+
it = client.events(device_id, event_types)
|
|
357
|
+
# Bounded drain: collect up to N events or until the quiet
|
|
358
|
+
# timeout. The iterator is from BridgeClient.events (SSE
|
|
359
|
+
# → structured DebugEvent); we do NOT hold it open forever
|
|
360
|
+
# (stdio is request/response, see _SUBSCRIBE_EVENT_BATCH).
|
|
361
|
+
deadline = time.monotonic() + _SUBSCRIBE_EVENT_TIMEOUT_S
|
|
362
|
+
for ev in it:
|
|
363
|
+
events.append(ev)
|
|
364
|
+
if len(events) >= _SUBSCRIBE_EVENT_BATCH:
|
|
365
|
+
break
|
|
366
|
+
if time.monotonic() >= deadline:
|
|
367
|
+
break
|
|
368
|
+
return events
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
events = await anyio.to_thread.run_sync(_drain)
|
|
372
|
+
except (BridgeError, DeviceUnreachable) as exc:
|
|
373
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
374
|
+
|
|
375
|
+
return [_event_to_jsonable(ev) for ev in events]
|
|
376
|
+
|
|
377
|
+
# -- device-management tools (DevicePool + LanScan) --------------
|
|
378
|
+
async def h_list_devices(args):
|
|
379
|
+
def _list():
|
|
380
|
+
return [_device_record_to_jsonable(r) for r in pool.list_all()]
|
|
381
|
+
return await anyio.to_thread.run_sync(_list)
|
|
382
|
+
|
|
383
|
+
async def h_discover_devices(args):
|
|
384
|
+
# force: today neither UsbIdentity nor LanScan has a cache, so the
|
|
385
|
+
# flag is a no-op at runtime; we still log it so the assembly point
|
|
386
|
+
# is visible (BF004/006 will own cache bypass when they land).
|
|
387
|
+
force = bool(args.get("force", False))
|
|
388
|
+
if force:
|
|
389
|
+
logger.info(
|
|
390
|
+
"discover_devices force=True "
|
|
391
|
+
"(cache bypass reserved for BF004/006)"
|
|
392
|
+
)
|
|
393
|
+
def _discover():
|
|
394
|
+
vpn_immune = VpnImmune()
|
|
395
|
+
lan_scan = LanScan(vpn_immune=vpn_immune)
|
|
396
|
+
# BF004 UsbIdentity (Android adb + iOS flutter — never xcrun
|
|
397
|
+
# devicectl per memory ios16-device-devicectl-pitfall).
|
|
398
|
+
usb_identity = UsbIdentity()
|
|
399
|
+
usb_candidates = usb_identity.all_candidates()
|
|
400
|
+
lan_candidates = lan_scan.scan()
|
|
401
|
+
|
|
402
|
+
# BF006 CrossIdentify: D7 layered fallback chain. Returns
|
|
403
|
+
# DeviceRecords with device_id from USB identity (D9) —
|
|
404
|
+
# ambiguous devices are marked and still upserted so the
|
|
405
|
+
# AI/developer can see them and resolve via register_device.
|
|
406
|
+
cross = CrossIdentify()
|
|
407
|
+
records = cross.identify(usb_candidates, lan_candidates)
|
|
408
|
+
|
|
409
|
+
# LAN-only fallback: a device that's reachable on LAN but has
|
|
410
|
+
# no USB candidate (cable unplugged, iOS over WiFi-only, etc.)
|
|
411
|
+
# shouldn't be invisible. The D9 identity source for these is
|
|
412
|
+
# the LAN /hello.deviceId (NOT USB) — this is the documented
|
|
413
|
+
# "LAN-only" degrade path. We synthesize a record keyed on
|
|
414
|
+
# the LAN host so the device is addressable; the AI can still
|
|
415
|
+
# operate it via the normal device_id → resolve_ip → forward
|
|
416
|
+
# chain. (Identity collision across devices with the same
|
|
417
|
+
# R019-fixed deviceId is the known limitation here, which is
|
|
418
|
+
# exactly why USB-first is preferred when available.)
|
|
419
|
+
bound_hosts = {rec.last_known_host for rec in records}
|
|
420
|
+
for cand in lan_candidates:
|
|
421
|
+
if cand.host in bound_hosts:
|
|
422
|
+
continue
|
|
423
|
+
nt = cand.network_target
|
|
424
|
+
rec = DeviceRecord(
|
|
425
|
+
device_id=nt.device_id or f"lan-{cand.host}",
|
|
426
|
+
label=nt.device_name or cand.host,
|
|
427
|
+
source="auto",
|
|
428
|
+
last_known_host=cand.host,
|
|
429
|
+
last_seen=time.time(),
|
|
430
|
+
hardware_name=nt.hardware_name,
|
|
431
|
+
machine_id=nt.machine_id,
|
|
432
|
+
platform=nt.platform,
|
|
433
|
+
network_target=nt,
|
|
434
|
+
)
|
|
435
|
+
records.append(rec)
|
|
436
|
+
|
|
437
|
+
# CrossIdentify leaves last_seen=None on USB+LAN paired
|
|
438
|
+
# records by design (it's pure / time-free — see
|
|
439
|
+
# cross_identify.py _merge). The LAN-only fallback branch
|
|
440
|
+
# above already stamps last_seen=time.time() on its
|
|
441
|
+
# synthesized records; without the same treatment here, the
|
|
442
|
+
# D9 *preferred* path (USB+LAN paired) becomes un-operable
|
|
443
|
+
# right after discover: resolve_ip → is_ip_fresh sees
|
|
444
|
+
# last_seen=None → False → stale → TTL expired on the very
|
|
445
|
+
# next connect/dpad/get_state. USB+LAN pairing means the
|
|
446
|
+
# host just answered /hello, so stamping now is semantically
|
|
447
|
+
# correct. (R020 BF012 e2e fix.)
|
|
448
|
+
now = time.time()
|
|
449
|
+
for rec in records:
|
|
450
|
+
if rec.last_seen is None and rec.last_known_host is not None:
|
|
451
|
+
rec.last_seen = now
|
|
452
|
+
pool.upsert(rec)
|
|
453
|
+
return records
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
records = await anyio.to_thread.run_sync(_discover)
|
|
457
|
+
except BridgeError as exc:
|
|
458
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
459
|
+
except Exception as exc: # noqa: BLE001 — discovery may hit env errors
|
|
460
|
+
# Wrap non-bridge discovery failures (subnet parse, command
|
|
461
|
+
# exec, ...) so the AI still gets a structured error rather
|
|
462
|
+
# than the server crashing.
|
|
463
|
+
raise McpError(types.ErrorData(
|
|
464
|
+
code=-32603,
|
|
465
|
+
message=f"discovery failed: {exc}",
|
|
466
|
+
)) from exc
|
|
467
|
+
|
|
468
|
+
# Discovery likely changed the pool; refresh every known device's
|
|
469
|
+
# schema and notify (§5.5 ②③).
|
|
470
|
+
await self._maybe_emit_list_changed_all()
|
|
471
|
+
return [_device_record_to_jsonable(r) for r in records]
|
|
472
|
+
|
|
473
|
+
async def h_register_device(args):
|
|
474
|
+
host = args["host"]
|
|
475
|
+
port = int(args.get("port", _DEFAULT_PORT))
|
|
476
|
+
# Validate port range up-front so an out-of-range value is a clean
|
|
477
|
+
# local error rather than an opaque HTTP connect failure later.
|
|
478
|
+
if not (1 <= port <= 65535):
|
|
479
|
+
raise ValueError(f"port out of range: {port}")
|
|
480
|
+
label = args.get("label")
|
|
481
|
+
note = f"port={port}" if port != _DEFAULT_PORT else None
|
|
482
|
+
|
|
483
|
+
# BF007 ManualRegistry owns the probe + identity + pool upsert.
|
|
484
|
+
# It accepts a LanScan instance for forward-compat (per BF007
|
|
485
|
+
# design — register does NOT call scan(), it only does a single-
|
|
486
|
+
# host probe via probe_hello). A failed probe returns
|
|
487
|
+
# RegisterResult(ok=False) with a DeviceUnreachable, which we
|
|
488
|
+
# translate into the MCP device_unreachable error (analysis L3:
|
|
489
|
+
# a failed register must NOT leave a phantom entry in the pool —
|
|
490
|
+
# ManualRegistry honors this by construction: it only upserts on
|
|
491
|
+
# a successful probe).
|
|
492
|
+
def _register():
|
|
493
|
+
lan_scan = LanScan(vpn_immune=VpnImmune())
|
|
494
|
+
registry = ManualRegistry(
|
|
495
|
+
pool=pool, lan_scan=lan_scan, port=port,
|
|
496
|
+
)
|
|
497
|
+
return registry.register(host, label=label, note=note)
|
|
498
|
+
|
|
499
|
+
result = await anyio.to_thread.run_sync(_register)
|
|
500
|
+
if not result.ok:
|
|
501
|
+
# ManualRegistry returned ok=False → probe failed, pool is
|
|
502
|
+
# untouched (no phantom entry). Surface as device_unreachable
|
|
503
|
+
# via the standard BridgeError → McpError translation so the
|
|
504
|
+
# AI gets the discover/register hint.
|
|
505
|
+
raise _bridge_error_to_mcp(result.error) # type: ignore[arg-type]
|
|
506
|
+
|
|
507
|
+
await self._maybe_emit_list_changed_for(result.record.device_id)
|
|
508
|
+
return _device_record_to_jsonable(result.record)
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
# ★ BF008-010 (Contract §0.1): 业务 async handler 注入接口合并.
|
|
512
|
+
# main() 不注入(tool_handlers=None)则为空 dict — 平面 server 零
|
|
513
|
+
# 业务 handler;业务装配(console-script)注入产品 handler(归
|
|
514
|
+
# R021-CLEANUP)。
|
|
515
|
+
**self._tool_handlers,
|
|
516
|
+
# meta tools (BF008 forward)
|
|
517
|
+
"invoke_command": h_invoke_command,
|
|
518
|
+
"read_resource": h_read_resource,
|
|
519
|
+
"list_capabilities": h_list_capabilities,
|
|
520
|
+
"get_state": h_get_state,
|
|
521
|
+
"subscribe_events": h_subscribe_events,
|
|
522
|
+
# device management (DevicePool + LanScan)
|
|
523
|
+
"list_devices": h_list_devices,
|
|
524
|
+
"discover_devices": h_discover_devices,
|
|
525
|
+
"register_device": h_register_device,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
# ------------------------------------------------------------------
|
|
529
|
+
# list_changed emission (§5.5 ②③; main path is ①, this is auxiliary)
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
async def _maybe_emit_list_changed_for(self, device_id: str) -> None:
|
|
533
|
+
"""Best-effort list_changed notification after a per-device change.
|
|
534
|
+
|
|
535
|
+
Runs :meth:`CapabilityMirror.refresh` (idempotent; returns the
|
|
536
|
+
change signal). On change, sends ``notifications/tools/list_changed``
|
|
537
|
+
via the current request's session. Failures are swallowed: the main
|
|
538
|
+
consistency path (§5.5 ① every tools/list recomputes) makes this a
|
|
539
|
+
SHOULD, not a MUST — a missed notification just means the client
|
|
540
|
+
re-lists on its own schedule.
|
|
541
|
+
"""
|
|
542
|
+
try:
|
|
543
|
+
def _refresh():
|
|
544
|
+
return self._mirror.refresh(device_id)
|
|
545
|
+
changed = await anyio.to_thread.run_sync(_refresh)
|
|
546
|
+
if changed:
|
|
547
|
+
await self._emit_list_changed()
|
|
548
|
+
except Exception: # noqa: BLE001
|
|
549
|
+
logger.debug("list_changed emission failed", exc_info=True)
|
|
550
|
+
|
|
551
|
+
async def _maybe_emit_list_changed_all(self) -> None:
|
|
552
|
+
"""Refresh every pooled device's schema + emit list_changed on change."""
|
|
553
|
+
try:
|
|
554
|
+
device_ids = [r.device_id for r in self._pool.list_all()]
|
|
555
|
+
any_changed = False
|
|
556
|
+
for device_id in device_ids:
|
|
557
|
+
def _refresh(d=device_id):
|
|
558
|
+
return self._mirror.refresh(d)
|
|
559
|
+
changed = await anyio.to_thread.run_sync(_refresh)
|
|
560
|
+
any_changed = any_changed or changed
|
|
561
|
+
if any_changed:
|
|
562
|
+
await self._emit_list_changed()
|
|
563
|
+
except Exception: # noqa: BLE001
|
|
564
|
+
logger.debug("list_changed bulk emission failed", exc_info=True)
|
|
565
|
+
|
|
566
|
+
async def _emit_list_changed(self) -> None:
|
|
567
|
+
"""Send ``notifications/tools/list_changed`` if a session is active.
|
|
568
|
+
|
|
569
|
+
``request_ctx`` is only set inside an in-flight request handler
|
|
570
|
+
(spike: ``Server._handle_request`` sets the contextvar, ``Server.run``
|
|
571
|
+
holds the session as a local). Calling this outside a handler is a
|
|
572
|
+
no-op, which is fine — list_changed is best-effort (design §5.5 D-1).
|
|
573
|
+
"""
|
|
574
|
+
try:
|
|
575
|
+
ctx = request_ctx.get()
|
|
576
|
+
except LookupError:
|
|
577
|
+
# Not inside a request (e.g. a hypothetical background poll). The
|
|
578
|
+
# main consistency path (tools/list recompute) still holds.
|
|
579
|
+
return
|
|
580
|
+
session = ctx.session
|
|
581
|
+
if session is not None:
|
|
582
|
+
await session.send_tool_list_changed()
|
|
583
|
+
|
|
584
|
+
# ------------------------------------------------------------------
|
|
585
|
+
# SDK handler registration (lazy; called from run_stdio)
|
|
586
|
+
# ------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
def _aggregate_tool_specs(self) -> list[ToolSpec]:
|
|
589
|
+
"""Build the global tool manifest for ``tools/list`` (design §5.5 ①).
|
|
590
|
+
|
|
591
|
+
The MCP ``tools/list`` protocol carries no ``device_id`` (the AI asks
|
|
592
|
+
for "everything you can do"), so we can't delegate to
|
|
593
|
+
``mirror.build_tools(device_id)`` directly. Instead we union:
|
|
594
|
+
|
|
595
|
+
1. the static floor (meta + device-mgmt, always present)
|
|
596
|
+
2. each pooled device's cached semantic-sugar tools
|
|
597
|
+
|
|
598
|
+
de-duplicated by name (BF010 owns its namespace so collisions only
|
|
599
|
+
happen if two devices expose the same same capability, in which case
|
|
600
|
+
the tool is identical and the union is correct).
|
|
601
|
+
|
|
602
|
+
Devices with no cached schema yet (never refreshed, or last refresh
|
|
603
|
+
failed) contribute nothing — they degrade to the static floor, which
|
|
604
|
+
is exactly the AC8 path. We skip them via the cheap no-I/O
|
|
605
|
+
:meth:`CapabilityMirror.schemas` check: ``build_tools(device_id)`` on
|
|
606
|
+
an uncached device returns the *whole static floor again*, so calling
|
|
607
|
+
it for every pooled device would rebuild the floor N times (all
|
|
608
|
+
immediately de-duped against ``seen_names`` — wasted work). Only
|
|
609
|
+
devices whose cache is populated can contribute new tools.
|
|
610
|
+
|
|
611
|
+
Factored out of the ``@app.list_tools()`` handler so the aggregation
|
|
612
|
+
logic is directly unit-testable without booting the SDK.
|
|
613
|
+
"""
|
|
614
|
+
specs = self._mirror.build_tools(None)
|
|
615
|
+
seen_names = {s.name for s in specs}
|
|
616
|
+
for record in self._pool.list_all():
|
|
617
|
+
if not self._mirror.schemas(record.device_id):
|
|
618
|
+
continue # uncached → contributes only the floor (already in specs)
|
|
619
|
+
for s in self._mirror.build_tools(record.device_id):
|
|
620
|
+
if s.name not in seen_names:
|
|
621
|
+
specs.append(s)
|
|
622
|
+
seen_names.add(s.name)
|
|
623
|
+
return specs
|
|
624
|
+
|
|
625
|
+
def _make_app(self) -> Server:
|
|
626
|
+
app = Server(self._server_name, version=self._server_version)
|
|
627
|
+
self._dispatch = self._build_dispatch()
|
|
628
|
+
dispatch = self._dispatch
|
|
629
|
+
|
|
630
|
+
@app.list_tools()
|
|
631
|
+
async def list_tools() -> list[types.Tool]:
|
|
632
|
+
# Design §5.5 ① — recompute the entire manifest on every call.
|
|
633
|
+
# The AI learns about new capabilities via ``list_capabilities``
|
|
634
|
+
# (which refreshes the cache) + the ``list_changed`` notification
|
|
635
|
+
# that follows (§5.5 ②③). Aggregation lives in
|
|
636
|
+
# :meth:`_aggregate_tool_specs` so it's unit-testable.
|
|
637
|
+
return [_tool_spec_to_mcp(s) for s in self._aggregate_tool_specs()]
|
|
638
|
+
|
|
639
|
+
@app.call_tool()
|
|
640
|
+
async def call_tool(name: str, arguments: dict):
|
|
641
|
+
handler = dispatch.get(name)
|
|
642
|
+
if handler is None:
|
|
643
|
+
# §5.5 ④ fallback — an AI holding a stale tool name gets a
|
|
644
|
+
# structured "unknown tool" error so it can self-correct.
|
|
645
|
+
raise McpError(types.ErrorData(
|
|
646
|
+
code=-32602,
|
|
647
|
+
message=f"Unknown tool: {name}",
|
|
648
|
+
))
|
|
649
|
+
# Handlers may raise McpError (BridgeError translation) OR a
|
|
650
|
+
# ValueError from a handler's own arg validation (e.g. business
|
|
651
|
+
# handler enum checks). Normalize ValueError into the same
|
|
652
|
+
# isError path so the AI sees a clean local-error message rather
|
|
653
|
+
# than a transport traceback.
|
|
654
|
+
try:
|
|
655
|
+
result = await handler(arguments or {})
|
|
656
|
+
except ValueError as exc:
|
|
657
|
+
raise McpError(types.ErrorData(
|
|
658
|
+
code=-32602,
|
|
659
|
+
message=f"invalid argument: {exc}",
|
|
660
|
+
)) from exc
|
|
661
|
+
|
|
662
|
+
# Result normalization: dict/list[Content]/CallToolResult/scalar.
|
|
663
|
+
return _normalize_tool_result(result)
|
|
664
|
+
|
|
665
|
+
return app
|
|
666
|
+
|
|
667
|
+
# ------------------------------------------------------------------
|
|
668
|
+
# Lifecycle (design §4.4 — run_stdio blocks on the AI session)
|
|
669
|
+
# ------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
async def _serve(self) -> None:
|
|
672
|
+
app = self._make_app()
|
|
673
|
+
self._app = app
|
|
674
|
+
init_opts = app.create_initialization_options(
|
|
675
|
+
notification_options=NotificationOptions(tools_changed=True)
|
|
676
|
+
)
|
|
677
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
678
|
+
await app.run(
|
|
679
|
+
read_stream,
|
|
680
|
+
write_stream,
|
|
681
|
+
init_opts,
|
|
682
|
+
# raise_exceptions=False keeps the server alive when a
|
|
683
|
+
# handler raises something we didn't translate — the SDK
|
|
684
|
+
# turns it into an error response instead of crashing the
|
|
685
|
+
# process (matches the §5.5 ④ "never crash" intent).
|
|
686
|
+
raise_exceptions=False,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
def run_stdio(self) -> None:
|
|
690
|
+
"""Block on stdio until the AI client closes stdin (design §4.4).
|
|
691
|
+
|
|
692
|
+
Synchronous entry point for ``main()`` and the console script. The
|
|
693
|
+
process exits when ``stdio_server`` observes EOF on stdin, which
|
|
694
|
+
happens when the AI session ends — this is NOT a daemon (design S7,
|
|
695
|
+
AC verified by the smoke test: subprocess stdin close → clean exit).
|
|
696
|
+
"""
|
|
697
|
+
anyio.run(self._serve)
|
|
698
|
+
|
|
699
|
+
# ------------------------------------------------------------------
|
|
700
|
+
# Test accessors (used by unit tests; not part of the public surface)
|
|
701
|
+
# ------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
def dispatch_for_test(self) -> dict[str, Any]:
|
|
704
|
+
"""Return the name → handler map for unit testing (no server start).
|
|
705
|
+
|
|
706
|
+
Lets tests exercise the dispatch logic (business/meta/device-mgmt
|
|
707
|
+
routing + error translation) without booting a stdio subprocess.
|
|
708
|
+
"""
|
|
709
|
+
if not self._dispatch:
|
|
710
|
+
self._dispatch = self._build_dispatch()
|
|
711
|
+
return self._dispatch
|
|
712
|
+
|
|
713
|
+
def call_handler_for_test(self, name: str):
|
|
714
|
+
"""Resolve a handler by name (raises KeyError if unknown)."""
|
|
715
|
+
if not self._dispatch:
|
|
716
|
+
self._dispatch = self._build_dispatch()
|
|
717
|
+
return self._dispatch[name]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
# ---------------------------------------------------------------------------
|
|
721
|
+
# Result normalization (handler return → MCP content blocks)
|
|
722
|
+
# ---------------------------------------------------------------------------
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _normalize_tool_result(result: Any) -> Any:
|
|
726
|
+
"""Coerce a handler's return value into an MCP call_tool result.
|
|
727
|
+
|
|
728
|
+
The SDK's ``@app.call_tool()`` handler accepts several shapes; we
|
|
729
|
+
standardize on:
|
|
730
|
+
|
|
731
|
+
* already a ``CallToolResult`` → returned as-is.
|
|
732
|
+
* list of ``Content`` blocks → returned as-is.
|
|
733
|
+
* ``dict`` / ``list`` / primitive → wrapped as a single TextContent
|
|
734
|
+
with a JSON dump (and the dict also passed as structuredContent
|
|
735
|
+
when it's a dict, so AI clients that prefer structured output see
|
|
736
|
+
it too).
|
|
737
|
+
"""
|
|
738
|
+
# Already-shaped results pass through. We require the *whole* list to be
|
|
739
|
+
# ContentBlocks — an empty list is ambiguous (could be data: "no devices"),
|
|
740
|
+
# so we treat it as data and JSON-encode it below (otherwise the SDK would
|
|
741
|
+
# treat it as "no content" and the AI would see an empty response).
|
|
742
|
+
if isinstance(result, types.CallToolResult):
|
|
743
|
+
return result
|
|
744
|
+
if isinstance(result, list) and result and all(_is_content_block(b) for b in result):
|
|
745
|
+
return result
|
|
746
|
+
|
|
747
|
+
# Plain data → JSON text (+ structuredContent for dicts).
|
|
748
|
+
# Pydantic models, dataclasses, and other structured values are JSON'd
|
|
749
|
+
# via _json_default (which handles model_dump / asdict / frozenset /
|
|
750
|
+
# tuple). We deliberately include these here rather than falling through
|
|
751
|
+
# to str(), so the AI sees structured JSON (e.g. a NetworkTarget) instead
|
|
752
|
+
# of an opaque Python repr.
|
|
753
|
+
import dataclasses as _dc
|
|
754
|
+
import json
|
|
755
|
+
is_struct = (
|
|
756
|
+
isinstance(result, (dict, list, bool, int, float, str))
|
|
757
|
+
or result is None
|
|
758
|
+
or hasattr(result, "model_dump") # pydantic v2
|
|
759
|
+
or _dc.is_dataclass(result)
|
|
760
|
+
)
|
|
761
|
+
if is_struct:
|
|
762
|
+
text = json.dumps(result, default=_json_default, ensure_ascii=False)
|
|
763
|
+
content = [types.TextContent(type="text", text=text)]
|
|
764
|
+
if isinstance(result, dict):
|
|
765
|
+
return types.CallToolResult(
|
|
766
|
+
content=content, structuredContent=result, isError=False
|
|
767
|
+
)
|
|
768
|
+
return content
|
|
769
|
+
|
|
770
|
+
# Fallback: stringify anything else.
|
|
771
|
+
return [types.TextContent(type="text", text=str(result))]
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _is_content_block(obj: Any) -> bool:
|
|
775
|
+
return isinstance(
|
|
776
|
+
obj,
|
|
777
|
+
(
|
|
778
|
+
types.TextContent,
|
|
779
|
+
types.ImageContent,
|
|
780
|
+
types.AudioContent,
|
|
781
|
+
types.ResourceLink,
|
|
782
|
+
types.EmbeddedResource,
|
|
783
|
+
),
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _json_default(obj: Any) -> Any:
|
|
788
|
+
"""JSON serializer for objects the stdlib json can't handle."""
|
|
789
|
+
# Frozen dataclasses / pydantic models (NetworkTarget, DebugEvent, ...).
|
|
790
|
+
if hasattr(obj, "model_dump"):
|
|
791
|
+
return obj.model_dump()
|
|
792
|
+
import dataclasses
|
|
793
|
+
if dataclasses.is_dataclass(obj):
|
|
794
|
+
return dataclasses.asdict(obj)
|
|
795
|
+
# Frozenset → sorted list (deterministic for capabilities).
|
|
796
|
+
if isinstance(obj, (frozenset, set)):
|
|
797
|
+
return sorted(obj)
|
|
798
|
+
# Tuple → list.
|
|
799
|
+
if isinstance(obj, tuple):
|
|
800
|
+
return list(obj)
|
|
801
|
+
raise TypeError(f"not JSON serializable: {type(obj).__name__}")
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# ---------------------------------------------------------------------------
|
|
805
|
+
# JSON-able views (avoid leaking internal state to the AI)
|
|
806
|
+
# ---------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _device_record_to_jsonable(rec: DeviceRecord) -> dict[str, Any]:
|
|
810
|
+
"""Return a public view of a DeviceRecord (design §4.2.1 — sanitized).
|
|
811
|
+
|
|
812
|
+
The MCP client never sees internal TTL / last_seen timestamps; it sees
|
|
813
|
+
the identity + display fields it needs to address a device (device_id,
|
|
814
|
+
label, source, platform, hardware_name, machine_id, connected flag).
|
|
815
|
+
"""
|
|
816
|
+
return {
|
|
817
|
+
"device_id": rec.device_id,
|
|
818
|
+
"label": rec.label,
|
|
819
|
+
"source": rec.source,
|
|
820
|
+
"platform": rec.platform,
|
|
821
|
+
"hardware_name": rec.hardware_name,
|
|
822
|
+
"machine_id": rec.machine_id,
|
|
823
|
+
"connected": rec.network_target.virtual_connected if rec.network_target else None,
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _schemas_to_jsonable(schemas) -> list[dict[str, Any]]:
|
|
828
|
+
"""Serialize CapabilitySchema[] for the ``list_capabilities`` tool."""
|
|
829
|
+
import dataclasses
|
|
830
|
+
out: list[dict[str, Any]] = []
|
|
831
|
+
for sch in schemas:
|
|
832
|
+
entry: dict[str, Any] = {
|
|
833
|
+
"capability_id": sch.capability_id,
|
|
834
|
+
"resources": [dataclasses.asdict(r) for r in sch.resources],
|
|
835
|
+
"commands": [dataclasses.asdict(c) for c in sch.commands],
|
|
836
|
+
}
|
|
837
|
+
if sch.description is not None:
|
|
838
|
+
entry["description"] = sch.description
|
|
839
|
+
out.append(entry)
|
|
840
|
+
return out
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _event_to_jsonable(ev) -> dict[str, Any]:
|
|
844
|
+
"""Serialize a DebugEvent for the ``subscribe_events`` tool."""
|
|
845
|
+
import dataclasses
|
|
846
|
+
return dataclasses.asdict(ev)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
# ---------------------------------------------------------------------------
|
|
850
|
+
# main() — design §4.4 assembly chain (verbatim)
|
|
851
|
+
# ---------------------------------------------------------------------------
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def main() -> None:
|
|
855
|
+
"""Assemble the adapter layer and run the stdio server (design §4.4).
|
|
856
|
+
|
|
857
|
+
Construction order follows the design's dependency chain (each line takes
|
|
858
|
+
the previous as a constructor arg, single-direction dependency §3.4):
|
|
859
|
+
|
|
860
|
+
DevicePool → BridgeClient → CapabilityMirror → McpServer → run_stdio
|
|
861
|
+
"""
|
|
862
|
+
logging.basicConfig(
|
|
863
|
+
level=logging.INFO,
|
|
864
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
865
|
+
)
|
|
866
|
+
pool = DevicePool(persist_path=Path.home() / ".pantas" / "devices.json")
|
|
867
|
+
client = BridgeClient(pool=pool)
|
|
868
|
+
mirror = CapabilityMirror(client=client)
|
|
869
|
+
server = McpServer(mirror=mirror, client=client, pool=pool)
|
|
870
|
+
server.run_stdio() # blocks; exits when the AI client closes stdin
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
if __name__ == "__main__":
|
|
874
|
+
main()
|