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.
@@ -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()