debug-control-plane 0.1.1__tar.gz → 0.3.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.1.1 → debug_control_plane-0.3.0}/PKG-INFO +5 -3
  2. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/README.md +3 -2
  3. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/__init__.py +1 -1
  4. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/bridge_client.py +136 -9
  5. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/capability_mirror.py +38 -7
  6. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/server.py +51 -4
  7. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/PKG-INFO +5 -3
  8. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/SOURCES.txt +2 -0
  9. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/requires.txt +1 -0
  10. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/pyproject.toml +7 -2
  11. debug_control_plane-0.3.0/tests/test_acceptance_flutter_app_auth.py +310 -0
  12. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_bridge_client.py +247 -1
  13. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_capability_mirror.py +91 -0
  14. debug_control_plane-0.3.0/tests/test_cross_lang_kotlin_plane.py +389 -0
  15. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_server.py +119 -1
  16. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/LICENSE +0 -0
  17. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/__init__.py +0 -0
  18. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/device_candidates.py +0 -0
  19. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/device_pool.py +0 -0
  20. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/__init__.py +0 -0
  21. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/cross_identify.py +0 -0
  22. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/lan_scan.py +0 -0
  23. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/manual_registry.py +0 -0
  24. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/usb_identity.py +0 -0
  25. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/discovery/vpn_immune.py +0 -0
  26. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/endpoint.py +0 -0
  27. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/device_discovery/protocol.py +0 -0
  28. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/__init__.py +0 -0
  29. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane/mcp_plane/semantic_provider.py +0 -0
  30. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/dependency_links.txt +0 -0
  31. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/entry_points.txt +0 -0
  32. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/debug_control_plane.egg-info/top_level.txt +0 -0
  33. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/setup.cfg +0 -0
  34. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_device_candidates.py +0 -0
  35. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_device_pool.py +0 -0
  36. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_cross_identify.py +0 -0
  37. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_lan_scan.py +0 -0
  38. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_manual_registry.py +0 -0
  39. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_usb_identity.py +0 -0
  40. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_discovery_vpn_immune.py +0 -0
  41. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_e2e_mock.py +0 -0
  42. {debug_control_plane-0.1.1 → debug_control_plane-0.3.0}/tests/test_endpoint.py +0 -0
  43. {debug_control_plane-0.1.1 → debug_control_plane-0.3.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.1.1
3
+ Version: 0.3.0
4
4
  Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
5
5
  Author: tangxiaolu
6
6
  License: MIT
@@ -14,6 +14,7 @@ Provides-Extra: test
14
14
  Requires-Dist: pytest>=7; extra == "test"
15
15
  Requires-Dist: pytest-cov>=4; extra == "test"
16
16
  Requires-Dist: ruff>=0.4; extra == "test"
17
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
17
18
  Dynamic: license-file
18
19
 
19
20
  # debug-control-plane (Python)
@@ -61,7 +62,7 @@ python/
61
62
  ├── README.md # this file
62
63
  ├── LICENSE # MIT
63
64
  └── debug_control_plane/
64
- ├── __init__.py # __version__ = "0.1.0"
65
+ ├── __init__.py # __version__ = "0.3.0"
65
66
  ├── device_discovery/ # USB/LAN device discovery + device pool
66
67
  │ ├── device_candidates.py
67
68
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -91,7 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
91
92
 
92
93
  ## Version / 版本
93
94
 
94
- `0.1.0` — initial release, API unstable. 首发,API 不稳定。
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
97
 
96
98
  ## License / 许可证
97
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.1.0"
46
+ ├── __init__.py # __version__ = "0.3.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,7 +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.1.0` — initial release, API unstable. 首发,API 不稳定。
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 不稳定。
77
78
 
78
79
  ## License / 许可证
79
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.1.0"
7
+ __version__ = "0.3.0"
@@ -54,8 +54,8 @@ Refs:
54
54
 
55
55
  from __future__ import annotations
56
56
 
57
- from collections.abc import Iterator
58
- from typing import TYPE_CHECKING, Any
57
+ from collections.abc import Iterator, Mapping
58
+ from typing import TYPE_CHECKING, Any, Protocol
59
59
 
60
60
  import httpx
61
61
 
@@ -138,6 +138,30 @@ class DeviceHttpError(BridgeError):
138
138
  super().__init__(f"{prefix}{detail}")
139
139
 
140
140
 
141
+ class DeviceAuthError(DeviceHttpError):
142
+ """The phone returned a stable debug auth failure code."""
143
+
144
+ def __init__(self, status_code: int, body: Any, code: str, message: str = "") -> None:
145
+ self.code = code
146
+ super().__init__(status_code, body, message or f" auth_code={code}")
147
+
148
+
149
+ class DebugAuthTokenProvider(Protocol):
150
+ """Per-device debug auth token provider.
151
+
152
+ Implementations own storage. DevicePool remains identity-only and must not
153
+ be used to persist bearer tokens.
154
+ """
155
+
156
+ def get_token(self, device_id: str) -> str | None: ...
157
+
158
+ def save_token(
159
+ self, device_id: str, token: str, metadata: Mapping[str, Any]
160
+ ) -> None: ...
161
+
162
+ def clear_token(self, device_id: str, reason: str) -> None: ...
163
+
164
+
141
165
  # ---------------------------------------------------------------------------
142
166
  # Service
143
167
  # ---------------------------------------------------------------------------
@@ -181,6 +205,7 @@ class BridgeClient:
181
205
  client: httpx.Client | None = None,
182
206
  request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
183
207
  stream_timeout: float = DEFAULT_STREAM_TIMEOUT,
208
+ token_provider: DebugAuthTokenProvider | None = None,
184
209
  ) -> None:
185
210
  self._pool = pool
186
211
  self._port = port
@@ -192,6 +217,7 @@ class BridgeClient:
192
217
  self._owns_client = True
193
218
  self._request_timeout = request_timeout
194
219
  self._stream_timeout = stream_timeout
220
+ self._token_provider = token_provider
195
221
 
196
222
  # ------------------------------------------------------------------
197
223
  # Lifecycle
@@ -282,17 +308,18 @@ class BridgeClient:
282
308
  """
283
309
  host = self.resolve(device_id)
284
310
  url = self._build_url(host, path)
311
+ headers = self._auth_headers(device_id)
285
312
  try:
286
313
  if isinstance(body, (dict, list)):
287
- resp = self._client.request(method, url, json=body)
314
+ resp = self._client.request(method, url, json=body, headers=headers)
288
315
  else:
289
- resp = self._client.request(method, url, content=body)
316
+ resp = self._client.request(method, url, content=body, headers=headers)
290
317
  except httpx.HTTPError as exc:
291
318
  # Connect refused / DNS / timeout / etc — surface as transport
292
319
  # failure so the caller catches a single exception type.
293
320
  raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
294
321
  if resp.status_code >= 400:
295
- raise DeviceHttpError(resp.status_code, _safe_body(resp))
322
+ raise self._http_error(device_id, resp)
296
323
  return _safe_body(resp)
297
324
 
298
325
  def read(self, device_id: str, path: list[str]) -> Any:
@@ -317,12 +344,13 @@ class BridgeClient:
317
344
  """
318
345
  host = self.resolve(device_id)
319
346
  url = self._build_url(host, ["hello"])
347
+ headers = self._auth_headers(device_id)
320
348
  try:
321
- resp = self._client.get(url)
349
+ resp = self._client.get(url, headers=headers)
322
350
  except httpx.HTTPError as exc:
323
351
  raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
324
352
  if resp.status_code != 200:
325
- raise DeviceHttpError(resp.status_code, _safe_body(resp))
353
+ raise self._http_error(device_id, resp)
326
354
  data = resp.json()
327
355
  if not isinstance(data, dict):
328
356
  raise DeviceHttpError(
@@ -370,13 +398,16 @@ class BridgeClient:
370
398
  """
371
399
  host = self.resolve(device_id)
372
400
  url = self._build_url(host, ["events"])
401
+ headers = self._auth_headers(device_id)
373
402
  type_set = set(event_types) if event_types else None
374
403
  try:
375
- with self._client.stream("GET", url, timeout=self._stream_timeout) as resp:
404
+ with self._client.stream(
405
+ "GET", url, headers=headers, timeout=self._stream_timeout
406
+ ) as resp:
376
407
  if resp.status_code >= 400:
377
408
  # Read the body so the caller sees the error payload.
378
409
  resp.read()
379
- raise DeviceHttpError(resp.status_code, _safe_body(resp))
410
+ raise self._http_error(device_id, resp)
380
411
  for raw_data, _event_field in _iter_sse(resp):
381
412
  try:
382
413
  payload = _parse_json_object(raw_data)
@@ -392,10 +423,75 @@ class BridgeClient:
392
423
  except httpx.HTTPError as exc:
393
424
  raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
394
425
 
426
+ def auth_request(
427
+ self,
428
+ device_id: str,
429
+ client_nonce: str,
430
+ *,
431
+ client_label: str | None = None,
432
+ requested_method: str | None = None,
433
+ requested_path: str | None = None,
434
+ ) -> Any:
435
+ """Create a pending App-side authorization request."""
436
+ body: dict[str, Any] = {"clientNonce": client_nonce}
437
+ if client_label is not None:
438
+ body["clientLabel"] = client_label
439
+ if requested_method is not None:
440
+ body["requestedMethod"] = requested_method
441
+ if requested_path is not None:
442
+ body["requestedPath"] = requested_path
443
+ return self.invoke(device_id, "POST", ["auth", "request"], body)
444
+
445
+ def auth_status(self, device_id: str, request_id: str, client_nonce: str) -> Any:
446
+ """Poll App-side authorization status."""
447
+ return self.invoke(
448
+ device_id,
449
+ "POST",
450
+ ["auth", "status"],
451
+ {"requestId": request_id, "clientNonce": client_nonce},
452
+ )
453
+
454
+ def auth_claim(self, device_id: str, request_id: str, client_nonce: str) -> Any:
455
+ """Claim an approved App-side authorization token and save it if present."""
456
+ body = self.invoke(
457
+ device_id,
458
+ "POST",
459
+ ["auth", "claim"],
460
+ {"requestId": request_id, "clientNonce": client_nonce},
461
+ )
462
+ if isinstance(body, dict):
463
+ token = body.get("token")
464
+ if isinstance(token, str) and self._token_provider is not None:
465
+ metadata = {
466
+ key: body[key]
467
+ for key in ("tokenId", "expiresAt")
468
+ if key in body
469
+ }
470
+ self._token_provider.save_token(device_id, token, metadata)
471
+ return body
472
+
395
473
  # ------------------------------------------------------------------
396
474
  # Internal helpers
397
475
  # ------------------------------------------------------------------
398
476
 
477
+ def _auth_headers(self, device_id: str) -> dict[str, str]:
478
+ provider = self._token_provider
479
+ if provider is None:
480
+ return {}
481
+ token = provider.get_token(device_id)
482
+ if not token:
483
+ return {}
484
+ return {"Authorization": f"Bearer {token}"}
485
+
486
+ def _http_error(self, device_id: str, resp: httpx.Response) -> DeviceHttpError:
487
+ body = _safe_body(resp)
488
+ code = _auth_error_code(resp.status_code, body)
489
+ if code is None:
490
+ return DeviceHttpError(resp.status_code, body)
491
+ if code in _CLEAR_TOKEN_AUTH_CODES and self._token_provider is not None:
492
+ self._token_provider.clear_token(device_id, reason=code)
493
+ return DeviceAuthError(resp.status_code, body, code=code)
494
+
399
495
  def _build_url(self, host: str, path: list[str]) -> str:
400
496
  """Assemble ``http://{host}:{port}/{seg1}/{seg2}...``.
401
497
 
@@ -512,12 +608,43 @@ def _parse_json_object(raw: str) -> dict[str, Any]:
512
608
  return parsed
513
609
 
514
610
 
611
+ def _auth_error_code(status_code: int, body: Any) -> str | None:
612
+ if status_code not in (401, 403) or not isinstance(body, dict):
613
+ return None
614
+ code = body.get("code")
615
+ if isinstance(code, str) and code in _AUTH_ERROR_CODES:
616
+ return code
617
+ return None
618
+
619
+
620
+ _AUTH_ERROR_CODES = frozenset(
621
+ {
622
+ "authorization_required",
623
+ "invalid_token",
624
+ "token_expired",
625
+ "token_revoked",
626
+ "authorization_denied",
627
+ "forbidden",
628
+ }
629
+ )
630
+
631
+ _CLEAR_TOKEN_AUTH_CODES = frozenset(
632
+ {
633
+ "invalid_token",
634
+ "token_expired",
635
+ "token_revoked",
636
+ }
637
+ )
638
+
639
+
515
640
  __all__ = [
516
641
  "DEFAULT_PORT",
517
642
  "DEFAULT_REQUEST_TIMEOUT",
518
643
  "DEFAULT_STREAM_TIMEOUT",
519
644
  "BridgeClient",
520
645
  "BridgeError",
646
+ "DebugAuthTokenProvider",
647
+ "DeviceAuthError",
521
648
  "DeviceHttpError",
522
649
  "DeviceStale",
523
650
  # AD-B9: DeviceUnreachable 已下沉 device_discovery.protocol,
@@ -78,6 +78,7 @@ from debug_control_plane.device_discovery.protocol import JsonMap, NetworkTarget
78
78
  from .bridge_client import (
79
79
  BridgeClient,
80
80
  BridgeError,
81
+ DeviceAuthError,
81
82
  DeviceHttpError,
82
83
  DeviceStale,
83
84
  DeviceUnreachable,
@@ -397,6 +398,12 @@ class CapabilityMirror:
397
398
  # (design §4.1 note: capability schema is CapabilityMirror's runtime
398
399
  # mirror, NOT a DeviceRecord field).
399
400
  self._cache: dict[str, list[CapabilitySchema]] = {}
401
+ # R001-BB001: per-device last auth error (DeviceAuthError). Auth
402
+ # failure ≠ offline — the schema cache is preserved and the signal is
403
+ # queryable via auth_error() instead of silently degrading. Cleared on
404
+ # the next successful refresh. GIL-guarded dict ops suffice (contract
405
+ # risk note).
406
+ self._auth_errors: dict[str, DeviceAuthError] = {}
400
407
 
401
408
  # ------------------------------------------------------------------
402
409
  # Provider registry (BF010 registration hook)
@@ -430,19 +437,31 @@ class CapabilityMirror:
430
437
  True iff the parsed schema differs from the cached snapshot
431
438
  (including "no cache → cache populated", which IS a change).
432
439
  False if /hello failed (cache cleared) or the schema is identical.
440
+ Auth errors (R001-BB001): cache is PRESERVED and False returned —
441
+ the phone is reachable, just unauthorized; see :meth:`auth_error`.
433
442
  """
434
443
  try:
435
444
  target = self._client.hello(device_id)
445
+ except DeviceAuthError as exc:
446
+ # R001-BB001: auth error ≠ offline. Preserve the schema cache
447
+ # (capabilities remain valid once authorized), record the auth
448
+ # failure for auth_error() queries, return False (no spurious
449
+ # list_changed). refresh stays poll-safe (never raises).
450
+ self._auth_errors[device_id] = exc
451
+ return False
436
452
  except (DeviceUnreachable, DeviceStale, DeviceHttpError, BridgeError):
437
- # Any /hello failure → degrade. Clear cache so build_tools falls
438
- # to static-only and a later successful refresh re-signals the
439
- # transition back. We do NOT report a change here: list_changed
440
- # signals "the manifest grew/shrank", and going from "had tools"
441
- # to "static only" is a real change — but only if we actually had
442
- # a non-empty manifest before. Reporting False on a cache→empty
443
- # transition would be a lie, so we surface it honestly.
453
+ # Any non-auth /hello failure → degrade. Clear cache (and any
454
+ # stale auth state — an unreachable phone has no auth verdict)
455
+ # so build_tools falls to static-only and a later successful
456
+ # refresh re-signals the transition back. We do NOT report a
457
+ # change here: list_changed signals "the manifest grew/shrank",
458
+ # and going from "had tools" to "static only" is a real change —
459
+ # but only if we actually had a non-empty manifest before.
460
+ # Reporting False on a cache→empty transition would be a lie, so
461
+ # we surface it honestly.
444
462
  had_cache = device_id in self._cache
445
463
  self._cache.pop(device_id, None)
464
+ self._auth_errors.pop(device_id, None)
446
465
  # Transition from "had dynamic tools" to "static only" IS a change
447
466
  # the AI client needs to know about (its cached tools are stale).
448
467
  return had_cache
@@ -452,8 +471,20 @@ class CapabilityMirror:
452
471
  changed = old_schemas != new_schemas
453
472
  if changed:
454
473
  self._cache[device_id] = new_schemas
474
+ # Successful /hello means the device is authorized again.
475
+ self._auth_errors.pop(device_id, None)
455
476
  return changed
456
477
 
478
+ def auth_error(self, device_id: str) -> DeviceAuthError | None:
479
+ """Return the last :class:`DeviceAuthError` seen for ``device_id``.
480
+
481
+ R001-BB001: lets callers (h_list_capabilities) distinguish "auth
482
+ failed" from "offline degrade" without refresh raising. ``None`` when
483
+ the device is healthy or the last failure was non-auth (offline /
484
+ stale / HTTP). Cleared by the next successful refresh.
485
+ """
486
+ return self._auth_errors.get(device_id)
487
+
457
488
  # ------------------------------------------------------------------
458
489
  # Cache access (no I/O)
459
490
  # ------------------------------------------------------------------
@@ -80,6 +80,7 @@ from debug_control_plane.device_discovery.discovery.vpn_immune import VpnImmune
80
80
  from .bridge_client import (
81
81
  BridgeClient,
82
82
  BridgeError,
83
+ DeviceAuthError,
83
84
  DeviceHttpError,
84
85
  DeviceStale,
85
86
  DeviceUnreachable,
@@ -141,6 +142,34 @@ def _tool_spec_to_mcp(spec: ToolSpec) -> types.Tool:
141
142
  # ---------------------------------------------------------------------------
142
143
 
143
144
 
145
+ # R001-BB001 (BB001.1/BB001.3): auth errors carry a stable ``code``
146
+ # (PROTOCOL.md §2.4). Map each code to the user's next step — never a bare
147
+ # "token validation failed". Token clearing already happened in BridgeClient
148
+ # (BF009); the MCP layer only surfaces the hint, and the message must never
149
+ # contain a plaintext token.
150
+ _TOKEN_CLEARED_ACTION = (
151
+ " — the locally cached token was cleared; re-authorize on the App "
152
+ "(open the App and grant access again), then retry"
153
+ )
154
+ _DENIED_ACTION = (
155
+ " — the App denied or does not grant this access; do not retry "
156
+ "automatically (check the App's permission settings or ask the "
157
+ "user to allow this client)"
158
+ )
159
+ _AUTH_CODE_ACTIONS: dict[str, str] = {
160
+ "authorization_required": (
161
+ " — open the App on the device and approve the authorization request "
162
+ "(this client is not yet authorized; no token was sent or accepted)"
163
+ ),
164
+ # Token-family codes share one remediation.
165
+ **dict.fromkeys(
166
+ ("token_expired", "token_revoked", "invalid_token"), _TOKEN_CLEARED_ACTION
167
+ ),
168
+ # authorization_denied / forbidden: no automatic retry.
169
+ **dict.fromkeys(("authorization_denied", "forbidden"), _DENIED_ACTION),
170
+ }
171
+
172
+
144
173
  def _bridge_error_to_mcp(exc: BridgeError | DeviceUnreachable) -> McpError:
145
174
  """Translate a :class:`BridgeError` into an MCP ``isError`` result.
146
175
 
@@ -158,7 +187,17 @@ def _bridge_error_to_mcp(exc: BridgeError | DeviceUnreachable) -> McpError:
158
187
  so AI clients that branch on the code treat it as a retryable user error
159
188
  rather than a fatal server fault.
160
189
  """
161
- if isinstance(exc, DeviceHttpError):
190
+ if isinstance(exc, DeviceAuthError):
191
+ # MUST precede the DeviceHttpError branch: DeviceAuthError is a
192
+ # DeviceHttpError subclass, so isinstance order decides which branch
193
+ # fires. Unknown codes fall back to the deny-style hint (safe default:
194
+ # no automatic retry).
195
+ action = _AUTH_CODE_ACTIONS.get(exc.code, _DENIED_ACTION)
196
+ message = (
197
+ f"device authorization error: auth_code={exc.code} "
198
+ f"status={exc.status_code}{action}"
199
+ )
200
+ elif isinstance(exc, DeviceHttpError):
162
201
  hint = ""
163
202
  # 409 real_controller_active: the phone's contract says the real pad
164
203
  # wins; surface that as an actionable hint (analysis fault injection).
@@ -217,7 +256,7 @@ class McpServer:
217
256
  pool: DevicePool,
218
257
  *,
219
258
  server_name: str = "mcp-debug-bridge",
220
- server_version: str = "0.1.0",
259
+ server_version: str = "0.3.0",
221
260
  providers: list[SemanticProvider] | None = None,
222
261
  tool_handlers: dict[str, Any] | None = None,
223
262
  ) -> None:
@@ -329,11 +368,19 @@ class McpServer:
329
368
  # list. We capture refresh's change signal here (one HTTP probe)
330
369
  # and feed it straight into ``_emit_list_changed`` below, rather
331
370
  # than re-probing via ``_maybe_emit_list_changed_for``.
371
+ # R001-BB001: refresh also stays non-raising for auth errors
372
+ # (recording them queryably instead); we surface them HERE as an
373
+ # MCP error so "unauthorized" is never silently degraded to an
374
+ # empty "no capabilities" answer.
332
375
  def _probe():
333
376
  changed = mirror.refresh(device_id)
334
- return mirror.schemas(device_id), changed
377
+ auth_err = mirror.auth_error(device_id)
378
+ return mirror.schemas(device_id), changed, auth_err
379
+
380
+ schemas, changed, auth_err = await anyio.to_thread.run_sync(_probe)
335
381
 
336
- schemas, changed = await anyio.to_thread.run_sync(_probe)
382
+ if auth_err is not None:
383
+ raise _bridge_error_to_mcp(auth_err)
337
384
 
338
385
  # Drive list_changed from this request's context (session is
339
386
  # reachable here — see module docstring spike note). Best-effort:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debug-control-plane
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
5
5
  Author: tangxiaolu
6
6
  License: MIT
@@ -14,6 +14,7 @@ Provides-Extra: test
14
14
  Requires-Dist: pytest>=7; extra == "test"
15
15
  Requires-Dist: pytest-cov>=4; extra == "test"
16
16
  Requires-Dist: ruff>=0.4; extra == "test"
17
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
17
18
  Dynamic: license-file
18
19
 
19
20
  # debug-control-plane (Python)
@@ -61,7 +62,7 @@ python/
61
62
  ├── README.md # this file
62
63
  ├── LICENSE # MIT
63
64
  └── debug_control_plane/
64
- ├── __init__.py # __version__ = "0.1.0"
65
+ ├── __init__.py # __version__ = "0.3.0"
65
66
  ├── device_discovery/ # USB/LAN device discovery + device pool
66
67
  │ ├── device_candidates.py
67
68
  │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
@@ -91,7 +92,8 @@ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no busi
91
92
 
92
93
  ## Version / 版本
93
94
 
94
- `0.1.0` — initial release, API unstable. 首发,API 不稳定。
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
97
 
96
98
  ## License / 许可证
97
99
 
@@ -24,8 +24,10 @@ 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
+ tests/test_acceptance_flutter_app_auth.py
27
28
  tests/test_bridge_client.py
28
29
  tests/test_capability_mirror.py
30
+ tests/test_cross_lang_kotlin_plane.py
29
31
  tests/test_device_candidates.py
30
32
  tests/test_device_pool.py
31
33
  tests/test_discovery_cross_identify.py
@@ -6,3 +6,4 @@ anyio<5,>=4
6
6
  pytest>=7
7
7
  pytest-cov>=4
8
8
  ruff>=0.4
9
+ pytest-asyncio>=0.23
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debug-control-plane"
7
- version = "0.1.1"
7
+ version = "0.3.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"
@@ -17,7 +17,9 @@ dependencies = [
17
17
  ]
18
18
 
19
19
  [project.optional-dependencies]
20
- test = ["pytest>=7", "pytest-cov>=4", "ruff>=0.4"]
20
+ # CI 首跑暴露: test_server.py 用 @pytest.mark.asyncio(strict 模式), 本地全局环境
21
+ # 碰巧装了 pytest-asyncio 所以七步门一直绿; runner 按 extras 声明装缺它 → async 测试全挂。
22
+ test = ["pytest>=7", "pytest-cov>=4", "ruff>=0.4", "pytest-asyncio>=0.23"]
21
23
 
22
24
  [project.scripts]
23
25
  # R021-CLEANUP-P1(方案 X): console-script 冲突解决。
@@ -38,6 +40,9 @@ include = ["debug_control_plane*"]
38
40
  line-length = 100
39
41
  target-version = "py310"
40
42
 
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "strict"
45
+
41
46
  [tool.ruff.lint]
42
47
  select = ["E", "F", "I", "W", "UP", "B"]
43
48
  ignore = ["E501"]