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.
Files changed (45) hide show
  1. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/PKG-INFO +4 -4
  2. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/README.md +3 -3
  3. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/__init__.py +1 -1
  4. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/bridge_client.py +57 -2
  5. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/capability_mirror.py +64 -0
  6. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/server.py +152 -8
  7. debug_control_plane-0.5.0/debug_control_plane/mcp_plane/token_provider.py +138 -0
  8. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/PKG-INFO +4 -4
  9. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/SOURCES.txt +3 -1
  10. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/pyproject.toml +1 -1
  11. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_bridge_client.py +96 -0
  12. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_capability_mirror.py +158 -0
  13. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_cross_lang_kotlin_plane.py +4 -3
  14. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_server.py +353 -2
  15. debug_control_plane-0.5.0/tests/test_token_provider.py +168 -0
  16. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/LICENSE +0 -0
  17. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/__init__.py +0 -0
  18. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
  19. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
  20. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
  21. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
  22. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
  23. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
  24. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
  25. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
  26. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
  27. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/device_discovery/protocol.py +0 -0
  28. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
  29. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
  30. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
  31. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
  32. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/requires.txt +0 -0
  33. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/debug_control_plane.egg-info/top_level.txt +0 -0
  34. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/setup.cfg +0 -0
  35. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_acceptance_flutter_app_auth.py +0 -0
  36. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_device_candidates.py +0 -0
  37. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_device_pool.py +0 -0
  38. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_cross_identify.py +0 -0
  39. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_lan_scan.py +0 -0
  40. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_manual_registry.py +0 -0
  41. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_usb_identity.py +0 -0
  42. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_discovery_vpn_immune.py +0 -0
  43. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_e2e_mock.py +0 -0
  44. {debug_control_plane-0.3.0 → debug_control_plane-0.5.0}/tests/test_endpoint.py +0 -0
  45. {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.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.3.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.3.0` — aligned with Kotlin/Dart/Flutter `0.3.0`, API unstable.
96
- `0.3.0` —— 与 Kotlin/Dart/Flutter `0.3.0` 对齐,API 不稳定。
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.3.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.3.0` — aligned with Kotlin/Dart/Flutter `0.3.0`, API unstable.
77
- `0.3.0` —— 与 Kotlin/Dart/Flutter `0.3.0` 对齐,API 不稳定。
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
 
@@ -4,4 +4,4 @@ Reusable across products: device discovery (USB/WiFi/identity) + MCP adapter
4
4
  (debug HTTP protocol → MCP tool surface). Extracted from an internal app
5
5
  (R019/R020, S2 Python slice, R021-BF004).
6
6
  """
7
- __version__ = "0.3.0"
7
+ __version__ = "0.5.0"
@@ -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(self, device_id: str, path: list[str]) -> Any:
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(device_id, "GET", path, None)
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",
@@ -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.3.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. capability_id is reserved for
346
- # routing/audit (the phone's path already encodes it) so it's
347
- # validated for presence by the schema but not forwarded.
348
- return await _run(
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
- return await _run(
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(pool=pool)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debug-control-plane
3
- Version: 0.3.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.3.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.3.0` — aligned with Kotlin/Dart/Flutter `0.3.0`, API unstable.
96
- `0.3.0` —— 与 Kotlin/Dart/Flutter `0.3.0` 对齐,API 不稳定。
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
 
@@ -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