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,642 @@
1
+ """Dynamic capability mirror — translate ``/hello`` into a tool manifest.
2
+
3
+ (R020-BF009)
4
+
5
+ Role (design §3.4 CapabilityMirror / §4.1 CapabilitySchema / §4.2 tools /
6
+ §5.2 dynamic-mirror decision flow / §5.5 tool-table consistency):
7
+
8
+ The CapabilityMirror is the **adapter layer** between the phone's runtime
9
+ capability schema (``/hello.registeredCapabilities``, FF002 output) and
10
+ the MCP tool surface. It produces a *tool manifest* (name / description /
11
+ JSON-schema input) that BF011 (McpServer) maps onto the MCP SDK.
12
+
13
+ Three orthogonal concerns (design §3.4 single-direction dependency):
14
+
15
+ * **Identity** (``device_id``) is stable — handled by :class:`DevicePool`.
16
+ * **HTTP probe** (``/hello``) is the only outbound arrow — owned by
17
+ :class:`BridgeClient.hello`.
18
+ * **Mirror** (this module) is pure logic: ``NetworkTarget`` →
19
+ ``list[ToolSpec]``. No SDK dependency, no I/O of its own.
20
+
21
+ Why a custom ToolSpec (not the mcp SDK ``Tool``)? BF009 is the **logic layer**
22
+ (the /hello → tool-list translation), BF011 is the **SDK integration layer**
23
+ (map ToolSpec → mcp Tool + register handler). Keeping them separate means
24
+ BF009 is unit-testable without booting a server and BF011 owns the single
25
+ SDK touchpoint (YAGNI + layered design).
26
+
27
+ Dynamic-mirror decision flow (design §5.2 — "app-not-running must not lie"):
28
+
29
+ * pool empty / device_id unknown → static meta + device-mgmt only
30
+ * /hello unreachable (offline/stale/HTTP error)
31
+ → clear cache, static only (degrade)
32
+ * new app (registeredCapabilities) → parse CapabilitySchema[]
33
+ * known cap (a SemanticProvider matches)
34
+ → semantic-sugar tools (BF010 gamepad)
35
+ * unknown cap (no provider matches) → D3 fallback: reachable ONLY via
36
+ the meta ``invoke_command`` tool
37
+ (no extra tool added)
38
+ * legacy app (no FF002, static ``capabilities`` only)
39
+ → E 方案 sentinel:对每 capability
40
+ tag 生成 sentinel schema 交
41
+ provider.matches 认领(AC8 degrade)
42
+
43
+ Why SemanticProvider is pluggable (decoupling BF010):
44
+
45
+ The gamepad semantic sugar is BF010's job. BF009 defines a *hook*
46
+ (``SemanticProvider`` protocol + a provider registry on CapabilityMirror)
47
+ so BF010 can register its gamepad provider without BF009 importing it.
48
+ BF009 ships with **no built-in provider**; its own tests inject a stub
49
+ provider to exercise the hook mechanism. This keeps BF009 free of any
50
+ forward dependency on BF010.
51
+
52
+ list_changed via pure polling (design §5.5 ③, this iteration only):
53
+
54
+ ``refresh(device_id)`` re-fetches /hello, parses the schema, and compares
55
+ it against the cached snapshot via :meth:`_diff_changed`. If the manifest
56
+ changed, the cache is rebuilt and ``True`` is returned so BF011 can emit
57
+ ``notifications/tools/list_changed``. **This iteration does NOT implement
58
+ the ``/events capability_changed`` real-time side channel** (R019
59
+ register/unregister don't emit it; design §5.5 D-3 lists it as plan-TBD).
60
+ BF009 only provides the change-detection signal; BF011 owns the actual
61
+ notification send.
62
+
63
+ Refs:
64
+ - tasks: .dev-flow/R020/mcp-bridge-device-discovery-tasks.md BF009
65
+ - design: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
66
+ §3.4 CapabilityMirror / §4.1 CapabilitySchema / §4.2 / §5.2 / §5.5
67
+ - test: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-test.md §2.1
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ from dataclasses import dataclass
73
+ from typing import Any, Protocol
74
+
75
+ from debug_control_plane.device_discovery.protocol import JsonMap, NetworkTarget
76
+
77
+ # BF007: 包内相对 bridge_client + 跨包 device_discovery.protocol(BF006 已迁)。
78
+ from .bridge_client import (
79
+ BridgeClient,
80
+ BridgeError,
81
+ DeviceHttpError,
82
+ DeviceStale,
83
+ DeviceUnreachable,
84
+ )
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # DTOs — capability schema mirror (design §4.1 CapabilitySchema)
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class ResourceDecl:
93
+ """A single REST resource declared by a phone capability.
94
+
95
+ Mirrors ``registeredCapabilities[].resources[]`` (FF002). ``description``
96
+ is optional — legacy resources registered without a description yield
97
+ ``None`` (FF002 D2 backward-compatible constructor).
98
+ """
99
+
100
+ method: str
101
+ path: tuple[str, ...]
102
+ description: str | None = None
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class CommandDecl:
107
+ """A single command declared by a phone capability.
108
+
109
+ Mirrors ``registeredCapabilities[].commands[]`` (FF002). ``description``
110
+ is optional for the same reason as :class:`ResourceDecl`.
111
+ """
112
+
113
+ method: str
114
+ path: tuple[str, ...]
115
+ description: str | None = None
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class CapabilitySchema:
120
+ """A single phone capability mirrored from ``/hello`` (design §4.1).
121
+
122
+ Frozen + tuple-valued so two schemas parsed from identical JSON compare
123
+ equal — :meth:`CapabilityMirror._diff_changed` relies on this for the
124
+ list_changed change-detection signal (design §5.5 ③).
125
+
126
+ Attributes:
127
+ capability_id: the capability ``id`` (e.g. ``gamepad``).
128
+ resources: declared REST resources (read side).
129
+ commands: declared commands (write side).
130
+ description: optional capability-level description (may be absent).
131
+ """
132
+
133
+ capability_id: str
134
+ resources: tuple[ResourceDecl, ...] = ()
135
+ commands: tuple[CommandDecl, ...] = ()
136
+ description: str | None = None
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # ToolSpec — manifest DTO (decoupled from mcp SDK, design D3)
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class ToolSpec:
146
+ """A single tool's manifest entry (name + description + input schema).
147
+
148
+ BF009 produces the *manifest*; BF011 maps each ToolSpec onto the MCP SDK
149
+ (``Tool`` + handler registration). ``input_schema`` is a JSON Schema dict
150
+ describing the tool's arguments (the MCP spec requires one per tool).
151
+ """
152
+
153
+ name: str
154
+ description: str
155
+ input_schema: dict[str, Any]
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # SemanticProvider — pluggable hook for known-capability sugar (BF010)
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ class SemanticProvider(Protocol):
164
+ """Builds semantic-sugar tools for a known capability.
165
+
166
+ BF010 (gamepad) implements this protocol and registers its instance on
167
+ :class:`CapabilityMirror` via the ``providers`` constructor arg. BF009
168
+ ships no built-in provider — the hook itself is exercised by a stub in
169
+ BF009's own tests.
170
+
171
+ Contract:
172
+ * :meth:`matches` is called once per parsed schema; return ``True`` if
173
+ this provider owns the capability (it then gets to build tools for
174
+ it). ``legacy_capabilities`` is the static ``/hello.capabilities``
175
+ frozenset (legacy/degrade mode); providers may match on it when no
176
+ structured schema is present (E 方案 sentinel 路径)。
177
+ * :meth:`build_tools` returns the manifest entries for the matched
178
+ capability. The schema is the structured mirror (when present) or a
179
+ synthetic legacy schema (capability_id set, empty resources/commands)
180
+ so providers don't have to special-case legacy mode.
181
+ """
182
+
183
+ def matches(
184
+ self,
185
+ schema: CapabilitySchema,
186
+ *,
187
+ legacy_capabilities: frozenset[str] | None = None,
188
+ ) -> bool:
189
+ """Return True if this provider owns the given capability."""
190
+ ...
191
+
192
+ def build_tools(self, schema: CapabilitySchema) -> list[ToolSpec]:
193
+ """Return the semantic-sugar tools for the matched capability."""
194
+ ...
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Static tool manifests — meta + device-management (design §4.2.1 / §4.2.2)
199
+ # ---------------------------------------------------------------------------
200
+
201
+ #: Common JSON-Schema fragment for the ``device_id`` parameter. Used across
202
+ #: all per-device tools so the spelling stays consistent.
203
+ _DEVICE_ID_PARAM: dict[str, Any] = {
204
+ "type": "object",
205
+ "properties": {
206
+ "device_id": {
207
+ "type": "string",
208
+ "description": "Stable device identity (from list_devices).",
209
+ },
210
+ },
211
+ "required": ["device_id"],
212
+ "additionalProperties": False,
213
+ }
214
+
215
+ #: Generic meta tools reachable for ANY capability (design §4.2.2). Always
216
+ #: present in the manifest regardless of /hello state — this is the floor
217
+ #: the AI can rely on even when no phone app is running (AC8 degrade + AC10
218
+ #: unknown-cap fallback both route through ``invoke_command``).
219
+ _STATIC_META_TOOLS: tuple[ToolSpec, ...] = (
220
+ ToolSpec(
221
+ name="list_capabilities",
222
+ description=(
223
+ "List the device's runtime capabilities (mirrors /hello). "
224
+ "Returns the capability schema array; each entry exposes "
225
+ "resources/commands reachable via invoke_command/read_resource."
226
+ ),
227
+ input_schema=_DEVICE_ID_PARAM,
228
+ ),
229
+ ToolSpec(
230
+ name="invoke_command",
231
+ description=(
232
+ "Forward an arbitrary capability command (unknown-capability "
233
+ "fallback). Pass capability_id + command_path segments + args "
234
+ "object; the phone's response is returned verbatim."
235
+ ),
236
+ input_schema={
237
+ "type": "object",
238
+ "properties": {
239
+ "device_id": {"type": "string"},
240
+ "capability_id": {"type": "string"},
241
+ "command_path": {
242
+ "type": "array",
243
+ "items": {"type": "string"},
244
+ "description": "URL path segments, e.g. [virtual, connect].",
245
+ },
246
+ "args": {
247
+ "type": ["object", "null"],
248
+ "description": "Request body (object) or null.",
249
+ "additionalProperties": True,
250
+ },
251
+ },
252
+ "required": ["device_id", "capability_id", "command_path"],
253
+ "additionalProperties": False,
254
+ },
255
+ ),
256
+ ToolSpec(
257
+ name="read_resource",
258
+ description=(
259
+ "Read an arbitrary capability resource (unknown-capability "
260
+ "fallback). Pass capability_id + resource_path segments; the "
261
+ "phone's response is returned verbatim."
262
+ ),
263
+ input_schema={
264
+ "type": "object",
265
+ "properties": {
266
+ "device_id": {"type": "string"},
267
+ "capability_id": {"type": "string"},
268
+ "resource_path": {
269
+ "type": "array",
270
+ "items": {"type": "string"},
271
+ },
272
+ "params": {
273
+ "type": ["object", "null"],
274
+ "additionalProperties": True,
275
+ },
276
+ },
277
+ "required": ["device_id", "capability_id", "resource_path"],
278
+ "additionalProperties": False,
279
+ },
280
+ ),
281
+ ToolSpec(
282
+ name="get_state",
283
+ description=(
284
+ "Read the device's aggregated state (mobile /state endpoint)."
285
+ ),
286
+ input_schema=_DEVICE_ID_PARAM,
287
+ ),
288
+ ToolSpec(
289
+ name="subscribe_events",
290
+ description=(
291
+ "Subscribe to the device's event stream (mobile /events SSE). "
292
+ "Optional event_types filter restricts which event types are "
293
+ "yielded."
294
+ ),
295
+ input_schema={
296
+ "type": "object",
297
+ "properties": {
298
+ "device_id": {"type": "string"},
299
+ "event_types": {
300
+ "type": "array",
301
+ "items": {"type": "string"},
302
+ },
303
+ },
304
+ "required": ["device_id"],
305
+ "additionalProperties": False,
306
+ },
307
+ ),
308
+ )
309
+
310
+ #: Device-management tools (design §4.2.1). Always present; they let the AI
311
+ #: discover/register/list devices even before any phone app is reachable.
312
+ _DEVICE_MGMT_TOOLS: tuple[ToolSpec, ...] = (
313
+ ToolSpec(
314
+ name="list_devices",
315
+ description="List all devices currently known to the pool.",
316
+ input_schema={
317
+ "type": "object",
318
+ "properties": {},
319
+ "additionalProperties": False,
320
+ },
321
+ ),
322
+ ToolSpec(
323
+ name="discover_devices",
324
+ description=(
325
+ "Trigger active USB + LAN device discovery and cross-identify "
326
+ "candidates into the pool. Set force=True to bypass the cache."
327
+ ),
328
+ input_schema={
329
+ "type": "object",
330
+ "properties": {
331
+ "force": {"type": "boolean", "default": False},
332
+ },
333
+ "additionalProperties": False,
334
+ },
335
+ ),
336
+ ToolSpec(
337
+ name="register_device",
338
+ description=(
339
+ "Register a device manually by host (fallback when discovery "
340
+ "can't find it). Probes /hello for the runtime label."
341
+ ),
342
+ input_schema={
343
+ "type": "object",
344
+ "properties": {
345
+ "host": {"type": "string"},
346
+ "port": {"type": "integer", "default": 18080},
347
+ "label": {"type": "string"},
348
+ },
349
+ "required": ["host"],
350
+ "additionalProperties": False,
351
+ },
352
+ ),
353
+ )
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Service — CapabilityMirror
358
+ # ---------------------------------------------------------------------------
359
+
360
+
361
+ class CapabilityMirror:
362
+ """Dynamic capability mirror: /hello → tool manifest (design §3.4 / §5.2).
363
+
364
+ The mirror is **stateless w.r.t. IPs** — every :meth:`refresh` and
365
+ :meth:`build_tools` call that needs /hello asks :class:`BridgeClient.hello`
366
+ (which itself asks the pool per call). The only state held here is the
367
+ parsed schema cache used for change detection (§5.5 ③).
368
+
369
+ Lifecycle:
370
+ * :meth:`refresh(device_id)` — re-fetch /hello, rebuild cache, signal
371
+ whether the manifest changed (for list_changed). Idempotent; safe
372
+ to call on a poll timer.
373
+ * :meth:`schemas(device_id)` — read the cached schemas (no I/O).
374
+ * :meth:`build_tools(device_id)` — snapshot-rebuild the manifest:
375
+ static meta + device-mgmt + dynamic (semantic sugar / unknown-cap
376
+ no-op fallback). ``device_id=None`` or no cache → static only
377
+ (AC8 degrade path).
378
+
379
+ Args:
380
+ client: BF008 BridgeClient — the /hello probe goes through here.
381
+ providers: optional list of :class:`SemanticProvider` (BF010
382
+ registers gamepad here). ``None`` ⇒ no semantic sugar at all
383
+ (every known capability degrades to the meta invoke_command).
384
+ """
385
+
386
+ def __init__(
387
+ self,
388
+ client: BridgeClient,
389
+ *,
390
+ providers: list[SemanticProvider] | None = None,
391
+ ) -> None:
392
+ self._client = client
393
+ # Snapshot list — providers are registered at construction. BF010
394
+ # will inject its gamepad instance; BF009's own tests use a stub.
395
+ self._providers: list[SemanticProvider] = list(providers or [])
396
+ # device_id → parsed schema snapshot. Runtime-only, never persisted
397
+ # (design §4.1 note: capability schema is CapabilityMirror's runtime
398
+ # mirror, NOT a DeviceRecord field).
399
+ self._cache: dict[str, list[CapabilitySchema]] = {}
400
+
401
+ # ------------------------------------------------------------------
402
+ # Provider registry (BF010 registration hook)
403
+ # ------------------------------------------------------------------
404
+
405
+ def register_provider(self, provider: SemanticProvider) -> None:
406
+ """Append a :class:`SemanticProvider` (BF010 registers gamepad here).
407
+
408
+ Idempotent in identity (no duplicate insert) but not in behavior:
409
+ registering after the first :meth:`build_tools` call does NOT
410
+ retroactively refresh the manifest — call :meth:`refresh` to pick up
411
+ the new provider.
412
+ """
413
+ if provider not in self._providers:
414
+ self._providers.append(provider)
415
+
416
+ # ------------------------------------------------------------------
417
+ # /hello probe + change detection (design §5.5 ③)
418
+ # ------------------------------------------------------------------
419
+
420
+ def refresh(self, device_id: str) -> bool:
421
+ """Re-probe /hello, rebuild cache, return True if manifest changed.
422
+
423
+ Used by BF011 on a poll timer to drive ``notifications/tools/list_changed``
424
+ (design §5.5 B/C). /hello failures are **degrade-only**: the cache is
425
+ cleared and ``False`` is returned (no spurious list_changed). The next
426
+ :meth:`build_tools` will fall back to static-only so the AI is never
427
+ lied to about a phone that's actually offline.
428
+
429
+ Returns:
430
+ True iff the parsed schema differs from the cached snapshot
431
+ (including "no cache → cache populated", which IS a change).
432
+ False if /hello failed (cache cleared) or the schema is identical.
433
+ """
434
+ try:
435
+ target = self._client.hello(device_id)
436
+ except (DeviceUnreachable, DeviceStale, DeviceHttpError, BridgeError):
437
+ # Any /hello failure → degrade. Clear cache so build_tools falls
438
+ # to static-only and a later successful refresh re-signals the
439
+ # transition back. We do NOT report a change here: list_changed
440
+ # signals "the manifest grew/shrank", and going from "had tools"
441
+ # to "static only" is a real change — but only if we actually had
442
+ # a non-empty manifest before. Reporting False on a cache→empty
443
+ # transition would be a lie, so we surface it honestly.
444
+ had_cache = device_id in self._cache
445
+ self._cache.pop(device_id, None)
446
+ # Transition from "had dynamic tools" to "static only" IS a change
447
+ # the AI client needs to know about (its cached tools are stale).
448
+ return had_cache
449
+
450
+ new_schemas = self._parse_schemas(target)
451
+ old_schemas = self._cache.get(device_id)
452
+ changed = old_schemas != new_schemas
453
+ if changed:
454
+ self._cache[device_id] = new_schemas
455
+ return changed
456
+
457
+ # ------------------------------------------------------------------
458
+ # Cache access (no I/O)
459
+ # ------------------------------------------------------------------
460
+
461
+ def schemas(self, device_id: str) -> list[CapabilitySchema]:
462
+ """Return the cached schemas for ``device_id`` (empty list if none).
463
+
464
+ Does NOT probe /hello — call :meth:`refresh` first. Returns a *copy*
465
+ so the caller can't mutate the cache via the returned reference.
466
+ """
467
+ cached = self._cache.get(device_id)
468
+ return list(cached) if cached else []
469
+
470
+ # ------------------------------------------------------------------
471
+ # Manifest rebuild (design §5.5 ① snapshot rebuild)
472
+ # ------------------------------------------------------------------
473
+
474
+ def build_tools(self, device_id: str | None = None) -> list[ToolSpec]:
475
+ """Snapshot-rebuild the tool manifest for ``device_id``.
476
+
477
+ Combines (in order, so semantic-sugar names never collide with meta
478
+ because BF010 owns its naming namespace):
479
+
480
+ 1. static meta tools (always present, design §4.2.2)
481
+ 2. device-management tools (always present, design §4.2.1)
482
+ 3. dynamic semantic-sugar tools (only when a provider matches)
483
+
484
+ Unknown-capability fallback (design §5.2 / AC10): a parsed capability
485
+ with no matching provider contributes NO extra tool — it stays
486
+ reachable only via the meta ``invoke_command``/``read_resource``
487
+ tools. This guarantees the AI can drive any capability even before
488
+ BF010 lands a dedicated sugar for it.
489
+
490
+ ``device_id=None`` or no cached schema → static meta + device-mgmt
491
+ only (the AC8 degrade path: AI learns there's nothing to operate on
492
+ and can discover/register first).
493
+
494
+ Returns:
495
+ A new list each call (snapshot — caller owns it). Tool names are
496
+ unique within the result by construction.
497
+ """
498
+ tools: list[ToolSpec] = list(_STATIC_META_TOOLS) + list(_DEVICE_MGMT_TOOLS)
499
+
500
+ if device_id is None:
501
+ return tools
502
+
503
+ cached = self._cache.get(device_id)
504
+ if not cached:
505
+ # No prior refresh (or last refresh failed) → degrade to static.
506
+ # We do NOT implicitly probe /hello here: build_tools is called
507
+ # on every tools/list and must stay cheap + never surprise the
508
+ # caller with a network round-trip. refresh() owns the probe.
509
+ return tools
510
+
511
+ for schema in cached:
512
+ tools.extend(self._build_sugar(schema))
513
+ return tools
514
+
515
+ def _build_sugar(self, schema: CapabilitySchema) -> list[ToolSpec]:
516
+ """Ask registered providers for semantic-sugar tools for one schema.
517
+
518
+ First-matching-provider wins (BF010 gamepad is the only realistic
519
+ provider this iteration). If no provider matches, the schema is
520
+ treated as "unknown capability" and contributes no tools (the D3
521
+ fallback: reachable only via meta invoke_command/read_resource).
522
+ """
523
+ for provider in self._providers:
524
+ if provider.matches(schema):
525
+ return provider.build_tools(schema)
526
+ return []
527
+
528
+ # ------------------------------------------------------------------
529
+ # /hello → CapabilitySchema parsing (design §4.1 CapabilitySchema)
530
+ # ------------------------------------------------------------------
531
+
532
+ def _parse_schemas(self, target: NetworkTarget) -> list[CapabilitySchema]:
533
+ """Parse the phone's runtime capability schema into CapabilitySchema[].
534
+
535
+ 平面零 gamepad 业务知识(AD-B1)。两条路径:
536
+
537
+ * **新 app**(FF002):``target.registered_capabilities`` 是 tuple
538
+ of dicts,每 dict 形如 ``{id, resources[], commands[]}``,逐个
539
+ 解析为 :class:`CapabilitySchema`(description 可选)。
540
+ * **Legacy app**(pre-FF002):``registered_capabilities`` 为 None,
541
+ 对 ``target.capabilities`` 每 tag 生成 **sentinel**
542
+ :class:`CapabilitySchema`(``capability_id=tag``, 空
543
+ ``resources``/``commands``),交 :meth:`SemanticProvider.matches`
544
+ 认领(E 方案 OI-B1 resolved)。平面不知 ``virtual_input``→
545
+ ``gamepad`` 映射(业务知识,业务 provider 内部按 tag 认领)。
546
+ * 两条信号均缺 → 空 schema list(build_tools 降级到 static-only)。
547
+ """
548
+ registered = target.registered_capabilities
549
+ if registered is not None:
550
+ return [
551
+ self._parse_one(cap)
552
+ for cap in registered
553
+ if isinstance(cap, dict)
554
+ ]
555
+
556
+ # Legacy degrade path: E 方案 sentinel(AD-B1,平面零 gamepad 业务知识)。
557
+ # 对 target.capabilities 每 tag 生成 sentinel CapabilitySchema,
558
+ # 交 provider.matches(schema) 认领(业务 provider 内部按 tag→capability
559
+ # 映射产 tools)。
560
+ if target.capabilities:
561
+ return [
562
+ CapabilitySchema(
563
+ capability_id=tag,
564
+ resources=(),
565
+ commands=(),
566
+ description=None,
567
+ )
568
+ for tag in target.capabilities
569
+ ]
570
+ return []
571
+
572
+ @staticmethod
573
+ def _parse_one(cap: JsonMap) -> CapabilitySchema:
574
+ """Parse a single registeredCapabilities entry into CapabilitySchema.
575
+
576
+ Defensive against malformed entries: a missing/non-string ``id`` yields
577
+ an empty-id schema (which won't match any provider, so it degrades to
578
+ the unknown-capability fallback). Resources/commands lists default to
579
+ empty when missing or malformed.
580
+ """
581
+ cap_id_raw = cap.get("id")
582
+ cap_id = cap_id_raw if isinstance(cap_id_raw, str) and cap_id_raw else ""
583
+
584
+ resources = _parse_decls(cap.get("resources"), ResourceDecl)
585
+ commands = _parse_decls(cap.get("commands"), CommandDecl)
586
+ description_raw = cap.get("description")
587
+ description = (
588
+ description_raw if isinstance(description_raw, str) and description_raw else None
589
+ )
590
+ return CapabilitySchema(
591
+ capability_id=cap_id,
592
+ resources=resources,
593
+ commands=commands,
594
+ description=description,
595
+ )
596
+
597
+
598
+ # ---------------------------------------------------------------------------
599
+ # Module-level parsing helpers (pure functions for testability)
600
+ # ---------------------------------------------------------------------------
601
+
602
+
603
+ def _parse_decls(
604
+ raw: Any,
605
+ decl_cls: type[ResourceDecl] | type[CommandDecl],
606
+ ) -> tuple[ResourceDecl, ...] | tuple[CommandDecl, ...]:
607
+ """Parse a resources[]/commands[] list into frozen decl tuples.
608
+
609
+ Each entry is expected to be ``{method, path, ?description}``. ``method``
610
+ and ``path`` are required and coerced defensively: a non-string method or
611
+ non-list path skips the entry (rather than raising) so one bad entry in
612
+ the phone's payload can't crash the whole manifest build. ``description``
613
+ is optional and stays ``None`` when absent or non-string.
614
+
615
+ The decl_cls argument selects between ResourceDecl and CommandDecl so the
616
+ same logic parses both; mypy can't follow the union return, so callers
617
+ treat the result as a sequence of the matching decl type.
618
+ """
619
+ if not isinstance(raw, list):
620
+ return () # type: ignore[return-value]
621
+ out: list[Any] = []
622
+ for item in raw:
623
+ if not isinstance(item, dict):
624
+ continue
625
+ method = item.get("method")
626
+ path = item.get("path")
627
+ if not isinstance(method, str) or not isinstance(path, list):
628
+ continue
629
+ description = item.get("description")
630
+ desc = description if isinstance(description, str) and description else None
631
+ out.append(decl_cls(method=method, path=tuple(str(p) for p in path), description=desc))
632
+ return tuple(out) # type: ignore[return-value]
633
+
634
+
635
+ __all__ = [
636
+ "CapabilityMirror",
637
+ "CapabilitySchema",
638
+ "CommandDecl",
639
+ "ResourceDecl",
640
+ "SemanticProvider",
641
+ "ToolSpec",
642
+ ]