debug-control-plane 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → debug_control_plane-0.4.0}/PKG-INFO +4 -4
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/README.md +3 -3
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/__init__.py +1 -1
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/bridge_client.py +57 -2
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/capability_mirror.py +64 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/server.py +148 -7
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/PKG-INFO +4 -4
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/pyproject.toml +1 -1
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_bridge_client.py +96 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_capability_mirror.py +158 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_cross_lang_kotlin_plane.py +4 -3
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_server.py +353 -2
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/LICENSE +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/__init__.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/protocol.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/SOURCES.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/requires.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/top_level.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/setup.cfg +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_acceptance_flutter_app_auth.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_device_candidates.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_device_pool.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_cross_identify.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_lan_scan.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_manual_registry.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_usb_identity.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_vpn_immune.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_e2e_mock.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_endpoint.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_import_sanity.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: debug-control-plane
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
|
|
5
5
|
Author: tangxiaolu
|
|
6
6
|
License: MIT
|
|
@@ -62,7 +62,7 @@ python/
|
|
|
62
62
|
├── README.md # this file
|
|
63
63
|
├── LICENSE # MIT
|
|
64
64
|
└── debug_control_plane/
|
|
65
|
-
├── __init__.py # __version__ = "0.
|
|
65
|
+
├── __init__.py # __version__ = "0.4.0"
|
|
66
66
|
├── device_discovery/ # USB/LAN device discovery + device pool
|
|
67
67
|
│ ├── device_candidates.py
|
|
68
68
|
│ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
|
|
@@ -92,8 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
|
|
|
92
92
|
|
|
93
93
|
## Version / 版本
|
|
94
94
|
|
|
95
|
-
`0.
|
|
96
|
-
`0.
|
|
95
|
+
`0.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
|
|
96
|
+
`0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.0` 对齐,API 不稳定。
|
|
97
97
|
|
|
98
98
|
## License / 许可证
|
|
99
99
|
|
|
@@ -43,7 +43,7 @@ python/
|
|
|
43
43
|
├── README.md # this file
|
|
44
44
|
├── LICENSE # MIT
|
|
45
45
|
└── debug_control_plane/
|
|
46
|
-
├── __init__.py # __version__ = "0.
|
|
46
|
+
├── __init__.py # __version__ = "0.4.0"
|
|
47
47
|
├── device_discovery/ # USB/LAN device discovery + device pool
|
|
48
48
|
│ ├── device_candidates.py
|
|
49
49
|
│ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
|
|
@@ -73,8 +73,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
|
|
|
73
73
|
|
|
74
74
|
## Version / 版本
|
|
75
75
|
|
|
76
|
-
`0.
|
|
77
|
-
`0.
|
|
76
|
+
`0.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
|
|
77
|
+
`0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.0` 对齐,API 不稳定。
|
|
78
78
|
|
|
79
79
|
## License / 许可证
|
|
80
80
|
|
|
@@ -279,6 +279,11 @@ class BridgeClient:
|
|
|
279
279
|
method: str,
|
|
280
280
|
path: list[str],
|
|
281
281
|
body: Any = None,
|
|
282
|
+
*,
|
|
283
|
+
capability_id: str | None = None,
|
|
284
|
+
scope: str | None = None,
|
|
285
|
+
page_id: str | None = None,
|
|
286
|
+
scope_revision: int | None = None,
|
|
282
287
|
) -> Any:
|
|
283
288
|
"""Forward ``method path body`` to the phone (byte-level pass-through).
|
|
284
289
|
|
|
@@ -295,6 +300,9 @@ class BridgeClient:
|
|
|
295
300
|
body: request body. If it's a dict/list it's sent as JSON
|
|
296
301
|
(``json=``); otherwise it's sent raw (``content=``) and may
|
|
297
302
|
be ``None``.
|
|
303
|
+
capability_id/scope/page_id/scope_revision: optional R003
|
|
304
|
+
selector metadata forwarded as ``X-DCP-*`` headers. Omitted
|
|
305
|
+
values keep the legacy flat dispatch behavior.
|
|
298
306
|
|
|
299
307
|
Returns:
|
|
300
308
|
The phone's response body, parsed: dict/list for JSON, ``str``
|
|
@@ -309,6 +317,14 @@ class BridgeClient:
|
|
|
309
317
|
host = self.resolve(device_id)
|
|
310
318
|
url = self._build_url(host, path)
|
|
311
319
|
headers = self._auth_headers(device_id)
|
|
320
|
+
headers.update(
|
|
321
|
+
selector_headers(
|
|
322
|
+
capability_id=capability_id,
|
|
323
|
+
scope=scope,
|
|
324
|
+
page_id=page_id,
|
|
325
|
+
scope_revision=scope_revision,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
312
328
|
try:
|
|
313
329
|
if isinstance(body, (dict, list)):
|
|
314
330
|
resp = self._client.request(method, url, json=body, headers=headers)
|
|
@@ -322,13 +338,31 @@ class BridgeClient:
|
|
|
322
338
|
raise self._http_error(device_id, resp)
|
|
323
339
|
return _safe_body(resp)
|
|
324
340
|
|
|
325
|
-
def read(
|
|
341
|
+
def read(
|
|
342
|
+
self,
|
|
343
|
+
device_id: str,
|
|
344
|
+
path: list[str],
|
|
345
|
+
*,
|
|
346
|
+
capability_id: str | None = None,
|
|
347
|
+
scope: str | None = None,
|
|
348
|
+
page_id: str | None = None,
|
|
349
|
+
scope_revision: int | None = None,
|
|
350
|
+
) -> Any:
|
|
326
351
|
"""GET convenience for ``read_resource`` / ``get_state``.
|
|
327
352
|
|
|
328
353
|
Equivalent to ``invoke(device_id, "GET", path, None)`` but signals
|
|
329
354
|
intent at the call site. Returns the parsed phone body.
|
|
330
355
|
"""
|
|
331
|
-
return self.invoke(
|
|
356
|
+
return self.invoke(
|
|
357
|
+
device_id,
|
|
358
|
+
"GET",
|
|
359
|
+
path,
|
|
360
|
+
None,
|
|
361
|
+
capability_id=capability_id,
|
|
362
|
+
scope=scope,
|
|
363
|
+
page_id=page_id,
|
|
364
|
+
scope_revision=scope_revision,
|
|
365
|
+
)
|
|
332
366
|
|
|
333
367
|
def hello(self, device_id: str) -> NetworkTarget:
|
|
334
368
|
"""Fetch ``/hello`` and parse to a typed :class:`NetworkTarget`.
|
|
@@ -530,6 +564,26 @@ def _safe_body(resp: httpx.Response) -> Any:
|
|
|
530
564
|
return resp.text
|
|
531
565
|
|
|
532
566
|
|
|
567
|
+
def selector_headers(
|
|
568
|
+
*,
|
|
569
|
+
capability_id: str | None = None,
|
|
570
|
+
scope: str | None = None,
|
|
571
|
+
page_id: str | None = None,
|
|
572
|
+
scope_revision: int | None = None,
|
|
573
|
+
) -> dict[str, str]:
|
|
574
|
+
"""Build optional R003 selector headers for capability-scoped dispatch."""
|
|
575
|
+
headers: dict[str, str] = {}
|
|
576
|
+
if isinstance(capability_id, str) and capability_id:
|
|
577
|
+
headers["X-DCP-Capability-Id"] = capability_id
|
|
578
|
+
if scope in ("app", "page"):
|
|
579
|
+
headers["X-DCP-Capability-Scope"] = scope
|
|
580
|
+
if isinstance(page_id, str) and page_id:
|
|
581
|
+
headers["X-DCP-Page-Id"] = page_id
|
|
582
|
+
if isinstance(scope_revision, int) and not isinstance(scope_revision, bool):
|
|
583
|
+
headers["X-DCP-Scope-Revision"] = str(scope_revision)
|
|
584
|
+
return headers
|
|
585
|
+
|
|
586
|
+
|
|
533
587
|
def _quote_segment(seg: str) -> str:
|
|
534
588
|
"""URL-encode a single path segment (preserving ``/`` within a segment).
|
|
535
589
|
|
|
@@ -647,6 +701,7 @@ __all__ = [
|
|
|
647
701
|
"DeviceAuthError",
|
|
648
702
|
"DeviceHttpError",
|
|
649
703
|
"DeviceStale",
|
|
704
|
+
"selector_headers",
|
|
650
705
|
# AD-B9: DeviceUnreachable 已下沉 device_discovery.protocol,
|
|
651
706
|
# 本模块 forward import(BF007),不 re-export。
|
|
652
707
|
]
|
|
@@ -129,12 +129,21 @@ class CapabilitySchema:
|
|
|
129
129
|
resources: declared REST resources (read side).
|
|
130
130
|
commands: declared commands (write side).
|
|
131
131
|
description: optional capability-level description (may be absent).
|
|
132
|
+
scope: ``app`` or ``page``; missing/malformed wire values downgrade
|
|
133
|
+
locally to ``app``.
|
|
134
|
+
page_id: optional business-provided page identity for page scope.
|
|
135
|
+
page_name: optional display metadata; never participates in tool names.
|
|
136
|
+
scope_revision: optional integer mirror revision for stale detection.
|
|
132
137
|
"""
|
|
133
138
|
|
|
134
139
|
capability_id: str
|
|
135
140
|
resources: tuple[ResourceDecl, ...] = ()
|
|
136
141
|
commands: tuple[CommandDecl, ...] = ()
|
|
137
142
|
description: str | None = None
|
|
143
|
+
scope: str = "app"
|
|
144
|
+
page_id: str | None = None
|
|
145
|
+
page_name: str | None = None
|
|
146
|
+
scope_revision: int | None = None
|
|
138
147
|
|
|
139
148
|
|
|
140
149
|
# ---------------------------------------------------------------------------
|
|
@@ -249,6 +258,19 @@ _STATIC_META_TOOLS: tuple[ToolSpec, ...] = (
|
|
|
249
258
|
"description": "Request body (object) or null.",
|
|
250
259
|
"additionalProperties": True,
|
|
251
260
|
},
|
|
261
|
+
"scope": {
|
|
262
|
+
"type": "string",
|
|
263
|
+
"enum": ["app", "page"],
|
|
264
|
+
"description": "Optional capability scope selector.",
|
|
265
|
+
},
|
|
266
|
+
"page_id": {
|
|
267
|
+
"type": "string",
|
|
268
|
+
"description": "Optional page id selector for page scope.",
|
|
269
|
+
},
|
|
270
|
+
"scope_revision": {
|
|
271
|
+
"type": "integer",
|
|
272
|
+
"description": "Optional scope revision selector.",
|
|
273
|
+
},
|
|
252
274
|
},
|
|
253
275
|
"required": ["device_id", "capability_id", "command_path"],
|
|
254
276
|
"additionalProperties": False,
|
|
@@ -274,6 +296,19 @@ _STATIC_META_TOOLS: tuple[ToolSpec, ...] = (
|
|
|
274
296
|
"type": ["object", "null"],
|
|
275
297
|
"additionalProperties": True,
|
|
276
298
|
},
|
|
299
|
+
"scope": {
|
|
300
|
+
"type": "string",
|
|
301
|
+
"enum": ["app", "page"],
|
|
302
|
+
"description": "Optional capability scope selector.",
|
|
303
|
+
},
|
|
304
|
+
"page_id": {
|
|
305
|
+
"type": "string",
|
|
306
|
+
"description": "Optional page id selector for page scope.",
|
|
307
|
+
},
|
|
308
|
+
"scope_revision": {
|
|
309
|
+
"type": "integer",
|
|
310
|
+
"description": "Optional scope revision selector.",
|
|
311
|
+
},
|
|
277
312
|
},
|
|
278
313
|
"required": ["device_id", "capability_id", "resource_path"],
|
|
279
314
|
"additionalProperties": False,
|
|
@@ -595,6 +630,10 @@ class CapabilityMirror:
|
|
|
595
630
|
resources=(),
|
|
596
631
|
commands=(),
|
|
597
632
|
description=None,
|
|
633
|
+
scope="app",
|
|
634
|
+
page_id=None,
|
|
635
|
+
page_name=None,
|
|
636
|
+
scope_revision=None,
|
|
598
637
|
)
|
|
599
638
|
for tag in target.capabilities
|
|
600
639
|
]
|
|
@@ -618,11 +657,19 @@ class CapabilityMirror:
|
|
|
618
657
|
description = (
|
|
619
658
|
description_raw if isinstance(description_raw, str) and description_raw else None
|
|
620
659
|
)
|
|
660
|
+
scope = _parse_scope(cap.get("scope"))
|
|
661
|
+
page_id = _parse_optional_string(cap.get("pageId"))
|
|
662
|
+
page_name = _parse_optional_string(cap.get("pageName"))
|
|
663
|
+
scope_revision = _parse_scope_revision(cap.get("scopeRevision"))
|
|
621
664
|
return CapabilitySchema(
|
|
622
665
|
capability_id=cap_id,
|
|
623
666
|
resources=resources,
|
|
624
667
|
commands=commands,
|
|
625
668
|
description=description,
|
|
669
|
+
scope=scope,
|
|
670
|
+
page_id=page_id,
|
|
671
|
+
page_name=page_name,
|
|
672
|
+
scope_revision=scope_revision,
|
|
626
673
|
)
|
|
627
674
|
|
|
628
675
|
|
|
@@ -663,6 +710,23 @@ def _parse_decls(
|
|
|
663
710
|
return tuple(out) # type: ignore[return-value]
|
|
664
711
|
|
|
665
712
|
|
|
713
|
+
def _parse_scope(raw: Any) -> str:
|
|
714
|
+
"""Parse registeredCapabilities[].scope with legacy-safe downgrade."""
|
|
715
|
+
return raw if raw in ("app", "page") else "app"
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _parse_optional_string(raw: Any) -> str | None:
|
|
719
|
+
"""Return non-empty string metadata, otherwise local downgrade to None."""
|
|
720
|
+
return raw if isinstance(raw, str) and raw else None
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _parse_scope_revision(raw: Any) -> int | None:
|
|
724
|
+
"""Return real integer revisions only; bool/malformed values downgrade."""
|
|
725
|
+
if isinstance(raw, bool):
|
|
726
|
+
return None
|
|
727
|
+
return raw if isinstance(raw, int) else None
|
|
728
|
+
|
|
729
|
+
|
|
666
730
|
__all__ = [
|
|
667
731
|
"CapabilityMirror",
|
|
668
732
|
"CapabilitySchema",
|
{debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/server.py
RENAMED
|
@@ -85,7 +85,7 @@ from .bridge_client import (
|
|
|
85
85
|
DeviceStale,
|
|
86
86
|
DeviceUnreachable,
|
|
87
87
|
)
|
|
88
|
-
from .capability_mirror import CapabilityMirror, ToolSpec
|
|
88
|
+
from .capability_mirror import CapabilityMirror, CapabilitySchema, ToolSpec
|
|
89
89
|
from .semantic_provider import SemanticProvider
|
|
90
90
|
|
|
91
91
|
# ★ BF008-010 (Contract §0.1 边界 1 收尾 + 方案 X): capability-specific semantic
|
|
@@ -256,7 +256,7 @@ class McpServer:
|
|
|
256
256
|
pool: DevicePool,
|
|
257
257
|
*,
|
|
258
258
|
server_name: str = "mcp-debug-bridge",
|
|
259
|
-
server_version: str = "0.
|
|
259
|
+
server_version: str = "0.4.0",
|
|
260
260
|
providers: list[SemanticProvider] | None = None,
|
|
261
261
|
tool_handlers: dict[str, Any] | None = None,
|
|
262
262
|
) -> None:
|
|
@@ -342,20 +342,25 @@ class McpServer:
|
|
|
342
342
|
# method+path, so we forward command_path as the path and pass
|
|
343
343
|
# the body verbatim. method defaults to POST (R19 commands are
|
|
344
344
|
# all POST per the debug-capability declarations); a future
|
|
345
|
-
# schema field could override it.
|
|
346
|
-
#
|
|
347
|
-
|
|
348
|
-
|
|
345
|
+
# schema field could override it. BF007 forwards capability_id
|
|
346
|
+
# plus optional scope fields as selector headers when provided.
|
|
347
|
+
selector = _selector_from_meta_args(args)
|
|
348
|
+
await self._ensure_page_selector_current(args["device_id"], selector)
|
|
349
|
+
return await self._run_meta_call(
|
|
349
350
|
client.invoke,
|
|
350
351
|
args["device_id"], "POST",
|
|
351
352
|
list(args.get("command_path", [])),
|
|
352
353
|
args.get("args"),
|
|
354
|
+
selector=selector,
|
|
353
355
|
)
|
|
354
356
|
|
|
355
357
|
async def h_read_resource(args):
|
|
356
|
-
|
|
358
|
+
selector = _selector_from_meta_args(args)
|
|
359
|
+
await self._ensure_page_selector_current(args["device_id"], selector)
|
|
360
|
+
return await self._run_meta_call(
|
|
357
361
|
client.read,
|
|
358
362
|
args["device_id"], list(args.get("resource_path", [])),
|
|
363
|
+
selector=selector,
|
|
359
364
|
)
|
|
360
365
|
|
|
361
366
|
async def h_list_capabilities(args):
|
|
@@ -572,6 +577,75 @@ class McpServer:
|
|
|
572
577
|
"register_device": h_register_device,
|
|
573
578
|
}
|
|
574
579
|
|
|
580
|
+
async def _ensure_page_selector_current(
|
|
581
|
+
self,
|
|
582
|
+
device_id: str,
|
|
583
|
+
selector: dict[str, Any],
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Reject stale page-scoped meta calls before forwarding to the App."""
|
|
586
|
+
if selector.get("scope") != "page":
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
schemas = self._mirror.schemas(device_id)
|
|
590
|
+
if not schemas:
|
|
591
|
+
schemas = await self._refresh_selector_snapshot(device_id)
|
|
592
|
+
|
|
593
|
+
if _has_page_capability(schemas, selector):
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
raise McpError(types.ErrorData(
|
|
597
|
+
code=-32602,
|
|
598
|
+
message=(
|
|
599
|
+
"page capability stale: "
|
|
600
|
+
f"capability_id={selector['capability_id']!r} "
|
|
601
|
+
f"page_id={selector['page_id']!r}; "
|
|
602
|
+
"call list_capabilities/tools list and retry with a current selector"
|
|
603
|
+
),
|
|
604
|
+
))
|
|
605
|
+
|
|
606
|
+
async def _refresh_selector_snapshot(
|
|
607
|
+
self,
|
|
608
|
+
device_id: str,
|
|
609
|
+
) -> list[CapabilitySchema]:
|
|
610
|
+
"""Refresh an empty selector snapshot and surface auth distinctly."""
|
|
611
|
+
await anyio.to_thread.run_sync(lambda: self._mirror.refresh(device_id))
|
|
612
|
+
auth_err = self._mirror.auth_error(device_id)
|
|
613
|
+
if auth_err is not None:
|
|
614
|
+
raise _bridge_error_to_mcp(auth_err)
|
|
615
|
+
return self._mirror.schemas(device_id)
|
|
616
|
+
|
|
617
|
+
async def _run_meta_call(
|
|
618
|
+
self,
|
|
619
|
+
sync_fn,
|
|
620
|
+
device_id: str,
|
|
621
|
+
*args,
|
|
622
|
+
selector: dict[str, Any],
|
|
623
|
+
):
|
|
624
|
+
"""Run a meta dispatch and converge page stale App responses."""
|
|
625
|
+
try:
|
|
626
|
+
return await anyio.to_thread.run_sync(
|
|
627
|
+
lambda: sync_fn(device_id, *args, **selector)
|
|
628
|
+
)
|
|
629
|
+
except DeviceAuthError as exc:
|
|
630
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
631
|
+
except DeviceHttpError as exc:
|
|
632
|
+
if _is_page_scope_error(exc):
|
|
633
|
+
await self._refresh_after_page_scope_error(device_id)
|
|
634
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
635
|
+
except (BridgeError, DeviceUnreachable) as exc:
|
|
636
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
637
|
+
|
|
638
|
+
async def _refresh_after_page_scope_error(self, device_id: str) -> None:
|
|
639
|
+
"""Best-effort manifest refresh after App says a page selector is stale."""
|
|
640
|
+
try:
|
|
641
|
+
changed = await anyio.to_thread.run_sync(
|
|
642
|
+
lambda: self._mirror.refresh(device_id)
|
|
643
|
+
)
|
|
644
|
+
if changed:
|
|
645
|
+
await self._emit_list_changed()
|
|
646
|
+
except Exception: # noqa: BLE001
|
|
647
|
+
logger.debug("stale page capability refresh failed", exc_info=True)
|
|
648
|
+
|
|
575
649
|
# ------------------------------------------------------------------
|
|
576
650
|
# list_changed emission (§5.5 ②③; main path is ①, this is auxiliary)
|
|
577
651
|
# ------------------------------------------------------------------
|
|
@@ -880,13 +954,80 @@ def _schemas_to_jsonable(schemas) -> list[dict[str, Any]]:
|
|
|
880
954
|
"capability_id": sch.capability_id,
|
|
881
955
|
"resources": [dataclasses.asdict(r) for r in sch.resources],
|
|
882
956
|
"commands": [dataclasses.asdict(c) for c in sch.commands],
|
|
957
|
+
"scope": sch.scope,
|
|
883
958
|
}
|
|
884
959
|
if sch.description is not None:
|
|
885
960
|
entry["description"] = sch.description
|
|
961
|
+
if sch.page_id is not None:
|
|
962
|
+
entry["pageId"] = sch.page_id
|
|
963
|
+
if sch.page_name is not None:
|
|
964
|
+
entry["pageName"] = sch.page_name
|
|
965
|
+
if sch.scope_revision is not None:
|
|
966
|
+
entry["scopeRevision"] = sch.scope_revision
|
|
886
967
|
out.append(entry)
|
|
887
968
|
return out
|
|
888
969
|
|
|
889
970
|
|
|
971
|
+
def _selector_from_meta_args(args: dict[str, Any]) -> dict[str, Any]:
|
|
972
|
+
"""Return BridgeClient selector kwargs from meta-tool arguments."""
|
|
973
|
+
if not any(key in args for key in ("scope", "page_id", "scope_revision")):
|
|
974
|
+
return {}
|
|
975
|
+
|
|
976
|
+
capability_id = args.get("capability_id")
|
|
977
|
+
if not isinstance(capability_id, str) or not capability_id:
|
|
978
|
+
raise ValueError("capability_id is required when selector is provided")
|
|
979
|
+
|
|
980
|
+
scope = args.get("scope")
|
|
981
|
+
if scope not in ("app", "page"):
|
|
982
|
+
raise ValueError("scope must be app or page")
|
|
983
|
+
|
|
984
|
+
page_id = args.get("page_id")
|
|
985
|
+
if page_id is not None and (not isinstance(page_id, str) or not page_id):
|
|
986
|
+
raise ValueError("page_id must be a non-empty string")
|
|
987
|
+
if scope == "page" and page_id is None:
|
|
988
|
+
raise ValueError("page_id is required when scope=page")
|
|
989
|
+
if scope != "page" and page_id is not None:
|
|
990
|
+
raise ValueError("page_id requires scope=page")
|
|
991
|
+
|
|
992
|
+
scope_revision = args.get("scope_revision")
|
|
993
|
+
if scope_revision is not None and (
|
|
994
|
+
not isinstance(scope_revision, int) or isinstance(scope_revision, bool)
|
|
995
|
+
):
|
|
996
|
+
raise ValueError("scope_revision must be an integer")
|
|
997
|
+
|
|
998
|
+
return {
|
|
999
|
+
"capability_id": capability_id,
|
|
1000
|
+
"scope": scope,
|
|
1001
|
+
"page_id": page_id,
|
|
1002
|
+
"scope_revision": scope_revision,
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _has_page_capability(
|
|
1007
|
+
schemas: list[CapabilitySchema],
|
|
1008
|
+
selector: dict[str, Any],
|
|
1009
|
+
) -> bool:
|
|
1010
|
+
"""Return whether the mirror has the exact page-scoped capability."""
|
|
1011
|
+
return any(
|
|
1012
|
+
sch.capability_id == selector["capability_id"]
|
|
1013
|
+
and sch.scope == "page"
|
|
1014
|
+
and sch.page_id == selector["page_id"]
|
|
1015
|
+
for sch in schemas
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _is_page_scope_error(exc: DeviceHttpError) -> bool:
|
|
1020
|
+
"""True for App signals that mean the page-scoped tool cache is stale."""
|
|
1021
|
+
if exc.status_code not in (409, 410) or not isinstance(exc.body, dict):
|
|
1022
|
+
return False
|
|
1023
|
+
code = exc.body.get("errorCode") or exc.body.get("code")
|
|
1024
|
+
return (
|
|
1025
|
+
exc.status_code == 410 and code == "page_capability_gone"
|
|
1026
|
+
) or (
|
|
1027
|
+
exc.status_code == 409 and code == "capability_scope_expired"
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
|
|
890
1031
|
def _event_to_jsonable(ev) -> dict[str, Any]:
|
|
891
1032
|
"""Serialize a DebugEvent for the ``subscribe_events`` tool."""
|
|
892
1033
|
import dataclasses
|
{debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: debug-control-plane
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
|
|
5
5
|
Author: tangxiaolu
|
|
6
6
|
License: MIT
|
|
@@ -62,7 +62,7 @@ python/
|
|
|
62
62
|
├── README.md # this file
|
|
63
63
|
├── LICENSE # MIT
|
|
64
64
|
└── debug_control_plane/
|
|
65
|
-
├── __init__.py # __version__ = "0.
|
|
65
|
+
├── __init__.py # __version__ = "0.4.0"
|
|
66
66
|
├── device_discovery/ # USB/LAN device discovery + device pool
|
|
67
67
|
│ ├── device_candidates.py
|
|
68
68
|
│ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
|
|
@@ -92,8 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
|
|
|
92
92
|
|
|
93
93
|
## Version / 版本
|
|
94
94
|
|
|
95
|
-
`0.
|
|
96
|
-
`0.
|
|
95
|
+
`0.4.0` — aligned with Kotlin/Dart/Flutter `0.4.0`, API unstable.
|
|
96
|
+
`0.4.0` —— 与 Kotlin/Dart/Flutter `0.4.0` 对齐,API 不稳定。
|
|
97
97
|
|
|
98
98
|
## License / 许可证
|
|
99
99
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "debug-control-plane"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Multi-product reusable debug control plane (device discovery + MCP adapter)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -46,6 +46,7 @@ from debug_control_plane.mcp_plane.bridge_client import (
|
|
|
46
46
|
DeviceUnreachable,
|
|
47
47
|
_iter_sse,
|
|
48
48
|
_safe_body,
|
|
49
|
+
selector_headers,
|
|
49
50
|
)
|
|
50
51
|
|
|
51
52
|
# ---------------------------------------------------------------------------
|
|
@@ -333,6 +334,52 @@ class TestInvoke:
|
|
|
333
334
|
client.invoke("dev1", "POST", ["raw"], b"hello-bytes")
|
|
334
335
|
assert captured[0].content == b"hello-bytes"
|
|
335
336
|
|
|
337
|
+
def test_selector_headers_forwarded_with_auth_header(self):
|
|
338
|
+
captured: list[httpx.Request] = []
|
|
339
|
+
|
|
340
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
341
|
+
captured.append(req)
|
|
342
|
+
return _ok_json({"ok": True})
|
|
343
|
+
|
|
344
|
+
pool = _MockPool({"dev1": _fresh()}, [])
|
|
345
|
+
provider = _FakeTokenProvider({"dev1": "token-1"}, [], [], [])
|
|
346
|
+
client = _make_client(pool, handler, token_provider=provider)
|
|
347
|
+
|
|
348
|
+
client.invoke(
|
|
349
|
+
"dev1",
|
|
350
|
+
"POST",
|
|
351
|
+
["debug", "tap"],
|
|
352
|
+
{},
|
|
353
|
+
capability_id="debug",
|
|
354
|
+
scope="page",
|
|
355
|
+
page_id="page-a",
|
|
356
|
+
scope_revision=7,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
headers = captured[0].headers
|
|
360
|
+
assert headers["Authorization"] == "Bearer token-1"
|
|
361
|
+
assert headers["X-DCP-Capability-Id"] == "debug"
|
|
362
|
+
assert headers["X-DCP-Capability-Scope"] == "page"
|
|
363
|
+
assert headers["X-DCP-Page-Id"] == "page-a"
|
|
364
|
+
assert headers["X-DCP-Scope-Revision"] == "7"
|
|
365
|
+
|
|
366
|
+
def test_no_selector_keeps_legacy_headers(self):
|
|
367
|
+
captured: list[httpx.Request] = []
|
|
368
|
+
|
|
369
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
370
|
+
captured.append(req)
|
|
371
|
+
return _ok_json({"ok": True})
|
|
372
|
+
|
|
373
|
+
pool = _MockPool({"dev1": _fresh()}, [])
|
|
374
|
+
client = _make_client(pool, handler)
|
|
375
|
+
|
|
376
|
+
client.invoke("dev1", "POST", ["debug", "tap"], {})
|
|
377
|
+
|
|
378
|
+
assert "X-DCP-Capability-Id" not in captured[0].headers
|
|
379
|
+
assert "X-DCP-Capability-Scope" not in captured[0].headers
|
|
380
|
+
assert "X-DCP-Page-Id" not in captured[0].headers
|
|
381
|
+
assert "X-DCP-Scope-Revision" not in captured[0].headers
|
|
382
|
+
|
|
336
383
|
|
|
337
384
|
# ---------------------------------------------------------------------------
|
|
338
385
|
# auth token provider + auth error taxonomy (R001-BF009)
|
|
@@ -581,6 +628,55 @@ class TestRead:
|
|
|
581
628
|
assert result == [{"id": "a"}, {"id": "b"}]
|
|
582
629
|
assert captured[0].url.path == "/profiles"
|
|
583
630
|
|
|
631
|
+
def test_read_forwards_selector_headers(self):
|
|
632
|
+
captured: list[httpx.Request] = []
|
|
633
|
+
|
|
634
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
635
|
+
captured.append(req)
|
|
636
|
+
return _ok_json({"status": "ok"})
|
|
637
|
+
|
|
638
|
+
pool = _MockPool({"dev1": _fresh()}, [])
|
|
639
|
+
client = _make_client(pool, handler)
|
|
640
|
+
|
|
641
|
+
result = client.read(
|
|
642
|
+
"dev1",
|
|
643
|
+
["debug", "status"],
|
|
644
|
+
capability_id="debug",
|
|
645
|
+
scope="page",
|
|
646
|
+
page_id="page-a",
|
|
647
|
+
scope_revision=8,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
assert result == {"status": "ok"}
|
|
651
|
+
assert captured[0].method == "GET"
|
|
652
|
+
assert captured[0].headers["X-DCP-Capability-Id"] == "debug"
|
|
653
|
+
assert captured[0].headers["X-DCP-Capability-Scope"] == "page"
|
|
654
|
+
assert captured[0].headers["X-DCP-Page-Id"] == "page-a"
|
|
655
|
+
assert captured[0].headers["X-DCP-Scope-Revision"] == "8"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
class TestSelectorHeaders:
|
|
659
|
+
def test_selector_headers_stringifies_int_revision(self):
|
|
660
|
+
assert selector_headers(
|
|
661
|
+
capability_id="debug",
|
|
662
|
+
scope="page",
|
|
663
|
+
page_id="page-a",
|
|
664
|
+
scope_revision=3,
|
|
665
|
+
) == {
|
|
666
|
+
"X-DCP-Capability-Id": "debug",
|
|
667
|
+
"X-DCP-Capability-Scope": "page",
|
|
668
|
+
"X-DCP-Page-Id": "page-a",
|
|
669
|
+
"X-DCP-Scope-Revision": "3",
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
def test_selector_headers_ignores_malformed_optional_values(self):
|
|
673
|
+
assert selector_headers(
|
|
674
|
+
capability_id="debug",
|
|
675
|
+
scope="invalid",
|
|
676
|
+
page_id="",
|
|
677
|
+
scope_revision=True,
|
|
678
|
+
) == {"X-DCP-Capability-Id": "debug"}
|
|
679
|
+
|
|
584
680
|
|
|
585
681
|
# ---------------------------------------------------------------------------
|
|
586
682
|
# hello() → NetworkTarget (含 BF005 扩展字段)
|