debug-control-plane 0.3.0__tar.gz → 0.5.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.5.0}/PKG-INFO +4 -4
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/README.md +3 -3
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/__init__.py +1 -1
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/bridge_client.py +57 -2
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/capability_mirror.py +64 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/server.py +152 -8
- debug_control_plane-0.5.0/debug_control_plane/mcp_plane/token_provider.py +138 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/PKG-INFO +4 -4
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/SOURCES.txt +3 -1
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/pyproject.toml +1 -1
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_bridge_client.py +96 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_capability_mirror.py +158 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_cross_lang_kotlin_plane.py +4 -3
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_server.py +353 -2
- debug_control_plane-0.5.0/tests/test_token_provider.py +168 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/LICENSE +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/__init__.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/protocol.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/requires.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/top_level.txt +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/setup.cfg +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_acceptance_flutter_app_auth.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_device_candidates.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_device_pool.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_cross_identify.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_lan_scan.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_manual_registry.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_usb_identity.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_vpn_immune.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_e2e_mock.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_endpoint.py +0 -0
- {debug_control_plane-0.3.0 → debug_control_plane-0.5.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.5.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.5.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.5.0` — aligned with Kotlin/Dart/Flutter `0.5.0`, API unstable.
|
|
96
|
+
`0.5.0` —— 与 Kotlin/Dart/Flutter `0.5.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.5.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.5.0` — aligned with Kotlin/Dart/Flutter `0.5.0`, API unstable.
|
|
77
|
+
`0.5.0` —— 与 Kotlin/Dart/Flutter `0.5.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.5.0}/debug_control_plane/mcp_plane/server.py
RENAMED
|
@@ -85,8 +85,9 @@ 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
|
+
from .token_provider import FileTokenProvider
|
|
90
91
|
|
|
91
92
|
# ★ BF008-010 (Contract §0.1 边界 1 收尾 + 方案 X): capability-specific semantic
|
|
92
93
|
# sugar 已迁业务侧(强耦合产品 protocol,属业务知识)。平面 server 零业务依赖
|
|
@@ -256,7 +257,7 @@ class McpServer:
|
|
|
256
257
|
pool: DevicePool,
|
|
257
258
|
*,
|
|
258
259
|
server_name: str = "mcp-debug-bridge",
|
|
259
|
-
server_version: str = "0.
|
|
260
|
+
server_version: str = "0.5.0",
|
|
260
261
|
providers: list[SemanticProvider] | None = None,
|
|
261
262
|
tool_handlers: dict[str, Any] | None = None,
|
|
262
263
|
) -> None:
|
|
@@ -342,20 +343,25 @@ class McpServer:
|
|
|
342
343
|
# method+path, so we forward command_path as the path and pass
|
|
343
344
|
# the body verbatim. method defaults to POST (R19 commands are
|
|
344
345
|
# all POST per the debug-capability declarations); a future
|
|
345
|
-
# schema field could override it.
|
|
346
|
-
#
|
|
347
|
-
|
|
348
|
-
|
|
346
|
+
# schema field could override it. BF007 forwards capability_id
|
|
347
|
+
# plus optional scope fields as selector headers when provided.
|
|
348
|
+
selector = _selector_from_meta_args(args)
|
|
349
|
+
await self._ensure_page_selector_current(args["device_id"], selector)
|
|
350
|
+
return await self._run_meta_call(
|
|
349
351
|
client.invoke,
|
|
350
352
|
args["device_id"], "POST",
|
|
351
353
|
list(args.get("command_path", [])),
|
|
352
354
|
args.get("args"),
|
|
355
|
+
selector=selector,
|
|
353
356
|
)
|
|
354
357
|
|
|
355
358
|
async def h_read_resource(args):
|
|
356
|
-
|
|
359
|
+
selector = _selector_from_meta_args(args)
|
|
360
|
+
await self._ensure_page_selector_current(args["device_id"], selector)
|
|
361
|
+
return await self._run_meta_call(
|
|
357
362
|
client.read,
|
|
358
363
|
args["device_id"], list(args.get("resource_path", [])),
|
|
364
|
+
selector=selector,
|
|
359
365
|
)
|
|
360
366
|
|
|
361
367
|
async def h_list_capabilities(args):
|
|
@@ -572,6 +578,75 @@ class McpServer:
|
|
|
572
578
|
"register_device": h_register_device,
|
|
573
579
|
}
|
|
574
580
|
|
|
581
|
+
async def _ensure_page_selector_current(
|
|
582
|
+
self,
|
|
583
|
+
device_id: str,
|
|
584
|
+
selector: dict[str, Any],
|
|
585
|
+
) -> None:
|
|
586
|
+
"""Reject stale page-scoped meta calls before forwarding to the App."""
|
|
587
|
+
if selector.get("scope") != "page":
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
schemas = self._mirror.schemas(device_id)
|
|
591
|
+
if not schemas:
|
|
592
|
+
schemas = await self._refresh_selector_snapshot(device_id)
|
|
593
|
+
|
|
594
|
+
if _has_page_capability(schemas, selector):
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
raise McpError(types.ErrorData(
|
|
598
|
+
code=-32602,
|
|
599
|
+
message=(
|
|
600
|
+
"page capability stale: "
|
|
601
|
+
f"capability_id={selector['capability_id']!r} "
|
|
602
|
+
f"page_id={selector['page_id']!r}; "
|
|
603
|
+
"call list_capabilities/tools list and retry with a current selector"
|
|
604
|
+
),
|
|
605
|
+
))
|
|
606
|
+
|
|
607
|
+
async def _refresh_selector_snapshot(
|
|
608
|
+
self,
|
|
609
|
+
device_id: str,
|
|
610
|
+
) -> list[CapabilitySchema]:
|
|
611
|
+
"""Refresh an empty selector snapshot and surface auth distinctly."""
|
|
612
|
+
await anyio.to_thread.run_sync(lambda: self._mirror.refresh(device_id))
|
|
613
|
+
auth_err = self._mirror.auth_error(device_id)
|
|
614
|
+
if auth_err is not None:
|
|
615
|
+
raise _bridge_error_to_mcp(auth_err)
|
|
616
|
+
return self._mirror.schemas(device_id)
|
|
617
|
+
|
|
618
|
+
async def _run_meta_call(
|
|
619
|
+
self,
|
|
620
|
+
sync_fn,
|
|
621
|
+
device_id: str,
|
|
622
|
+
*args,
|
|
623
|
+
selector: dict[str, Any],
|
|
624
|
+
):
|
|
625
|
+
"""Run a meta dispatch and converge page stale App responses."""
|
|
626
|
+
try:
|
|
627
|
+
return await anyio.to_thread.run_sync(
|
|
628
|
+
lambda: sync_fn(device_id, *args, **selector)
|
|
629
|
+
)
|
|
630
|
+
except DeviceAuthError as exc:
|
|
631
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
632
|
+
except DeviceHttpError as exc:
|
|
633
|
+
if _is_page_scope_error(exc):
|
|
634
|
+
await self._refresh_after_page_scope_error(device_id)
|
|
635
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
636
|
+
except (BridgeError, DeviceUnreachable) as exc:
|
|
637
|
+
raise _bridge_error_to_mcp(exc) from exc
|
|
638
|
+
|
|
639
|
+
async def _refresh_after_page_scope_error(self, device_id: str) -> None:
|
|
640
|
+
"""Best-effort manifest refresh after App says a page selector is stale."""
|
|
641
|
+
try:
|
|
642
|
+
changed = await anyio.to_thread.run_sync(
|
|
643
|
+
lambda: self._mirror.refresh(device_id)
|
|
644
|
+
)
|
|
645
|
+
if changed:
|
|
646
|
+
await self._emit_list_changed()
|
|
647
|
+
except Exception: # noqa: BLE001
|
|
648
|
+
logger.debug("stale page capability refresh failed", exc_info=True)
|
|
649
|
+
|
|
575
650
|
# ------------------------------------------------------------------
|
|
576
651
|
# list_changed emission (§5.5 ②③; main path is ①, this is auxiliary)
|
|
577
652
|
# ------------------------------------------------------------------
|
|
@@ -880,13 +955,80 @@ def _schemas_to_jsonable(schemas) -> list[dict[str, Any]]:
|
|
|
880
955
|
"capability_id": sch.capability_id,
|
|
881
956
|
"resources": [dataclasses.asdict(r) for r in sch.resources],
|
|
882
957
|
"commands": [dataclasses.asdict(c) for c in sch.commands],
|
|
958
|
+
"scope": sch.scope,
|
|
883
959
|
}
|
|
884
960
|
if sch.description is not None:
|
|
885
961
|
entry["description"] = sch.description
|
|
962
|
+
if sch.page_id is not None:
|
|
963
|
+
entry["pageId"] = sch.page_id
|
|
964
|
+
if sch.page_name is not None:
|
|
965
|
+
entry["pageName"] = sch.page_name
|
|
966
|
+
if sch.scope_revision is not None:
|
|
967
|
+
entry["scopeRevision"] = sch.scope_revision
|
|
886
968
|
out.append(entry)
|
|
887
969
|
return out
|
|
888
970
|
|
|
889
971
|
|
|
972
|
+
def _selector_from_meta_args(args: dict[str, Any]) -> dict[str, Any]:
|
|
973
|
+
"""Return BridgeClient selector kwargs from meta-tool arguments."""
|
|
974
|
+
if not any(key in args for key in ("scope", "page_id", "scope_revision")):
|
|
975
|
+
return {}
|
|
976
|
+
|
|
977
|
+
capability_id = args.get("capability_id")
|
|
978
|
+
if not isinstance(capability_id, str) or not capability_id:
|
|
979
|
+
raise ValueError("capability_id is required when selector is provided")
|
|
980
|
+
|
|
981
|
+
scope = args.get("scope")
|
|
982
|
+
if scope not in ("app", "page"):
|
|
983
|
+
raise ValueError("scope must be app or page")
|
|
984
|
+
|
|
985
|
+
page_id = args.get("page_id")
|
|
986
|
+
if page_id is not None and (not isinstance(page_id, str) or not page_id):
|
|
987
|
+
raise ValueError("page_id must be a non-empty string")
|
|
988
|
+
if scope == "page" and page_id is None:
|
|
989
|
+
raise ValueError("page_id is required when scope=page")
|
|
990
|
+
if scope != "page" and page_id is not None:
|
|
991
|
+
raise ValueError("page_id requires scope=page")
|
|
992
|
+
|
|
993
|
+
scope_revision = args.get("scope_revision")
|
|
994
|
+
if scope_revision is not None and (
|
|
995
|
+
not isinstance(scope_revision, int) or isinstance(scope_revision, bool)
|
|
996
|
+
):
|
|
997
|
+
raise ValueError("scope_revision must be an integer")
|
|
998
|
+
|
|
999
|
+
return {
|
|
1000
|
+
"capability_id": capability_id,
|
|
1001
|
+
"scope": scope,
|
|
1002
|
+
"page_id": page_id,
|
|
1003
|
+
"scope_revision": scope_revision,
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _has_page_capability(
|
|
1008
|
+
schemas: list[CapabilitySchema],
|
|
1009
|
+
selector: dict[str, Any],
|
|
1010
|
+
) -> bool:
|
|
1011
|
+
"""Return whether the mirror has the exact page-scoped capability."""
|
|
1012
|
+
return any(
|
|
1013
|
+
sch.capability_id == selector["capability_id"]
|
|
1014
|
+
and sch.scope == "page"
|
|
1015
|
+
and sch.page_id == selector["page_id"]
|
|
1016
|
+
for sch in schemas
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _is_page_scope_error(exc: DeviceHttpError) -> bool:
|
|
1021
|
+
"""True for App signals that mean the page-scoped tool cache is stale."""
|
|
1022
|
+
if exc.status_code not in (409, 410) or not isinstance(exc.body, dict):
|
|
1023
|
+
return False
|
|
1024
|
+
code = exc.body.get("errorCode") or exc.body.get("code")
|
|
1025
|
+
return (
|
|
1026
|
+
exc.status_code == 410 and code == "page_capability_gone"
|
|
1027
|
+
) or (
|
|
1028
|
+
exc.status_code == 409 and code == "capability_scope_expired"
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
890
1032
|
def _event_to_jsonable(ev) -> dict[str, Any]:
|
|
891
1033
|
"""Serialize a DebugEvent for the ``subscribe_events`` tool."""
|
|
892
1034
|
import dataclasses
|
|
@@ -911,7 +1053,9 @@ def main() -> None:
|
|
|
911
1053
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
912
1054
|
)
|
|
913
1055
|
pool = DevicePool(persist_path=Path.home() / ".debug-control-plane" / "devices.json")
|
|
914
|
-
client = BridgeClient(
|
|
1056
|
+
client = BridgeClient(
|
|
1057
|
+
pool=pool, token_provider=FileTokenProvider()
|
|
1058
|
+
)
|
|
915
1059
|
mirror = CapabilityMirror(client=client)
|
|
916
1060
|
server = McpServer(mirror=mirror, client=client, pool=pool)
|
|
917
1061
|
server.run_stdio() # blocks; exits when the AI client closes stdin
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""File-backed debug auth token provider (R004-BF001).
|
|
2
|
+
|
|
3
|
+
FileTokenProvider is the production implementation of
|
|
4
|
+
:class:`debug_control_plane.mcp_plane.bridge_client.DebugAuthTokenProvider`.
|
|
5
|
+
It persists per-device bearer tokens to ``~/.debug-control-plane/tokens.json``
|
|
6
|
+
so tokens survive python process restarts (design R004 §3.2).
|
|
7
|
+
|
|
8
|
+
Schema (independent from devices.json — no shared structure or imports)::
|
|
9
|
+
|
|
10
|
+
{"version": 1, "tokens": {device_id: {"token": ..., "tokenId": ...,
|
|
11
|
+
"expiresAt": ...}}}
|
|
12
|
+
|
|
13
|
+
Concurrency model: lazy-loaded, single-threaded. This provider assumes it is
|
|
14
|
+
only used from the asyncio event-loop thread (the MCP server's threading
|
|
15
|
+
model). No locking is performed — the design explicitly accepted this
|
|
16
|
+
asyncio-single-thread assumption.
|
|
17
|
+
|
|
18
|
+
Security: plaintext tokens on the developer machine with 0600 permissions
|
|
19
|
+
(user-approved decision). The 0600 mode is guaranteed by creating the tmp
|
|
20
|
+
file via ``os.open(..., 0o600)`` (bypassing umask, so no 0644 window exists)
|
|
21
|
+
followed by an atomic ``os.replace``.
|
|
22
|
+
|
|
23
|
+
Corruption policy: a corrupt file or an unknown ``version`` silently falls
|
|
24
|
+
back to an empty store — tokens are regenerable and the server must not
|
|
25
|
+
crash on load.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Mapping
|
|
35
|
+
|
|
36
|
+
_SCHEMA_VERSION = 1
|
|
37
|
+
_DEFAULT_PATH = Path.home() / ".debug-control-plane" / "tokens.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_expired(value: str) -> bool:
|
|
41
|
+
"""Return True if ``value`` is a tz-aware ISO timestamp in the past.
|
|
42
|
+
|
|
43
|
+
Parse failures and naive timestamps are treated as *not* expired: the
|
|
44
|
+
phone's 401 is the final arbiter, and a parsing bug must not break the
|
|
45
|
+
auth chain (design §5). Evaluated at read time; never written back.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
return False
|
|
51
|
+
if parsed.tzinfo is None:
|
|
52
|
+
return False
|
|
53
|
+
return parsed <= datetime.now(timezone.utc)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FileTokenProvider:
|
|
57
|
+
"""File-backed token store implementing the DebugAuthTokenProvider protocol."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
60
|
+
self._path = path or _DEFAULT_PATH
|
|
61
|
+
self._loaded = False
|
|
62
|
+
self._tokens: dict[str, dict[str, str]] = {}
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
# Protocol surface
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def get_token(self, device_id: str) -> str | None:
|
|
69
|
+
"""Return the stored token for ``device_id`` if present and unexpired."""
|
|
70
|
+
self._ensure_loaded()
|
|
71
|
+
row = self._tokens.get(device_id)
|
|
72
|
+
if row is None:
|
|
73
|
+
return None
|
|
74
|
+
expires_at = row.get("expiresAt", "")
|
|
75
|
+
if expires_at and _is_expired(expires_at):
|
|
76
|
+
return None
|
|
77
|
+
return row.get("token")
|
|
78
|
+
|
|
79
|
+
def save_token(
|
|
80
|
+
self, device_id: str, token: str, metadata: Mapping[str, Any]
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Store ``token`` for ``device_id`` and persist (full file rewrite)."""
|
|
83
|
+
self._ensure_loaded()
|
|
84
|
+
row: dict[str, str] = {"token": token}
|
|
85
|
+
for key, value in metadata.items():
|
|
86
|
+
row[key] = value if isinstance(value, str) else str(value)
|
|
87
|
+
self._tokens[device_id] = row
|
|
88
|
+
self._flush()
|
|
89
|
+
|
|
90
|
+
def clear_token(self, device_id: str, reason: str) -> None:
|
|
91
|
+
"""Drop the row for ``device_id`` and persist (rewrite without it)."""
|
|
92
|
+
self._ensure_loaded()
|
|
93
|
+
if device_id in self._tokens:
|
|
94
|
+
del self._tokens[device_id]
|
|
95
|
+
self._flush()
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Internals
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _ensure_loaded(self) -> None:
|
|
102
|
+
if self._loaded:
|
|
103
|
+
return
|
|
104
|
+
self._loaded = True
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
107
|
+
except (OSError, ValueError):
|
|
108
|
+
return # missing or corrupt → silent empty fallback
|
|
109
|
+
if not isinstance(data, dict) or data.get("version") != _SCHEMA_VERSION:
|
|
110
|
+
return # unknown schema → silent empty fallback
|
|
111
|
+
tokens = data.get("tokens")
|
|
112
|
+
if not isinstance(tokens, dict):
|
|
113
|
+
return
|
|
114
|
+
self._tokens = {
|
|
115
|
+
device_id: row
|
|
116
|
+
for device_id, row in tokens.items()
|
|
117
|
+
if isinstance(row, dict)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def _flush(self) -> None:
|
|
121
|
+
"""Atomically write the store with 0600 permissions."""
|
|
122
|
+
payload = json.dumps(
|
|
123
|
+
{"version": _SCHEMA_VERSION, "tokens": self._tokens},
|
|
124
|
+
indent=2,
|
|
125
|
+
ensure_ascii=False,
|
|
126
|
+
)
|
|
127
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
tmp_path = self._path.with_name(self._path.name + ".tmp")
|
|
129
|
+
fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
130
|
+
try:
|
|
131
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
132
|
+
f.write(payload)
|
|
133
|
+
f.flush()
|
|
134
|
+
os.fsync(f.fileno())
|
|
135
|
+
except BaseException:
|
|
136
|
+
tmp_path.unlink(missing_ok=True)
|
|
137
|
+
raise
|
|
138
|
+
os.replace(tmp_path, self._path)
|
{debug_control_plane-0.3.0 → debug_control_plane-0.5.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.5.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.5.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.5.0` — aligned with Kotlin/Dart/Flutter `0.5.0`, API unstable.
|
|
96
|
+
`0.5.0` —— 与 Kotlin/Dart/Flutter `0.5.0` 对齐,API 不稳定。
|
|
97
97
|
|
|
98
98
|
## License / 许可证
|
|
99
99
|
|
{debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/SOURCES.txt
RENAMED
|
@@ -24,6 +24,7 @@ debug_control_plane/mcp_plane/bridge_client.py
|
|
|
24
24
|
debug_control_plane/mcp_plane/capability_mirror.py
|
|
25
25
|
debug_control_plane/mcp_plane/semantic_provider.py
|
|
26
26
|
debug_control_plane/mcp_plane/server.py
|
|
27
|
+
debug_control_plane/mcp_plane/token_provider.py
|
|
27
28
|
tests/test_acceptance_flutter_app_auth.py
|
|
28
29
|
tests/test_bridge_client.py
|
|
29
30
|
tests/test_capability_mirror.py
|
|
@@ -38,4 +39,5 @@ tests/test_discovery_vpn_immune.py
|
|
|
38
39
|
tests/test_e2e_mock.py
|
|
39
40
|
tests/test_endpoint.py
|
|
40
41
|
tests/test_import_sanity.py
|
|
41
|
-
tests/test_server.py
|
|
42
|
+
tests/test_server.py
|
|
43
|
+
tests/test_token_provider.py
|