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.
Files changed (43) hide show
  1. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/PKG-INFO +4 -4
  2. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/README.md +3 -3
  3. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/__init__.py +1 -1
  4. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/bridge_client.py +57 -2
  5. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/capability_mirror.py +64 -0
  6. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/server.py +148 -7
  7. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/PKG-INFO +4 -4
  8. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/pyproject.toml +1 -1
  9. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_bridge_client.py +96 -0
  10. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_capability_mirror.py +158 -0
  11. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_cross_lang_kotlin_plane.py +4 -3
  12. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_server.py +353 -2
  13. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/LICENSE +0 -0
  14. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/__init__.py +0 -0
  15. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
  16. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
  17. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
  18. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
  19. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
  20. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
  21. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
  22. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
  23. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
  24. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/device_discovery/protocol.py +0 -0
  25. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
  26. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
  27. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/SOURCES.txt +0 -0
  28. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
  29. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
  30. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/requires.txt +0 -0
  31. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/debug_control_plane.egg-info/top_level.txt +0 -0
  32. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/setup.cfg +0 -0
  33. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_acceptance_flutter_app_auth.py +0 -0
  34. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_device_candidates.py +0 -0
  35. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_device_pool.py +0 -0
  36. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_cross_identify.py +0 -0
  37. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_lan_scan.py +0 -0
  38. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_manual_registry.py +0 -0
  39. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_usb_identity.py +0 -0
  40. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_discovery_vpn_immune.py +0 -0
  41. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_e2e_mock.py +0 -0
  42. {debug_control_plane-0.3.0 → debug_control_plane-0.4.0}/tests/test_endpoint.py +0 -0
  43. {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.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.3.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.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.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.3.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.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.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
 
@@ -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.4.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,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.3.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. 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(
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
- return await _run(
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debug-control-plane
3
- Version: 0.3.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.3.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.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.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.3.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 扩展字段)