deepcell-cli 0.6.1__py3-none-any.whl

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 (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,580 @@
1
+ """OAuth 2.1 provider for MCP remote authentication.
2
+
3
+ Implements the ``OAuthAuthorizationServerProvider`` protocol from the MCP SDK
4
+ so that AI platforms (Claude.ai, ChatGPT, Manus) can authenticate users via
5
+ OAuth 2.1 + PKCE before accessing DeepCell tools.
6
+
7
+ Storage:
8
+ - Client registrations: ``~/.deepcell/oauth_clients.json`` (persistent; the
9
+ ``deepcell-mcp`` container mounts a volume here so DCR survives redeploys).
10
+ - Authorization codes: in-memory dict with 10-min TTL.
11
+ - Refresh tokens: delegated to the Jingwei API. An MCP refresh token *is* a
12
+ Jingwei refresh token (opaque, hashed in Postgres), so rotation, token-family
13
+ reuse-detection and revocation live server-side and survive container
14
+ recreates — there is no in-memory refresh-token store.
15
+ - Access tokens: JWT issued by Jingwei and verified locally using the same
16
+ shared secret.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import secrets
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any
27
+ from urllib.parse import quote, urlparse
28
+ from uuid import UUID
29
+
30
+ import httpx
31
+ import jwt as pyjwt
32
+
33
+ from mcp.server.auth.provider import (
34
+ AccessToken,
35
+ AuthorizationCode,
36
+ AuthorizationParams,
37
+ AuthorizeError,
38
+ OAuthAuthorizationServerProvider,
39
+ RefreshToken,
40
+ TokenError,
41
+ construct_redirect_uri,
42
+ )
43
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
44
+
45
+ from deepcell_cli.config import _DIR, _ensure_dir, _write_private_file, client_headers
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ _CLIENTS_FILE = _DIR / "oauth_clients.json"
50
+
51
+ # Authorization codes expire after 10 minutes (RFC 6749 §4.1.2).
52
+ _AUTH_CODE_TTL = 600
53
+
54
+ # A pending authorization is a browser mid-consent, so it lives about as long as
55
+ # a person takes to sign in. Without a TTL a `session_id` stayed valid until the
56
+ # process restarted, which both kept phishing links alive indefinitely and let
57
+ # `/authorize` — reachable without any credential — grow the map without bound
58
+ # in a 512 MB container. The cap is the backstop for a burst inside one window.
59
+ _PENDING_AUTH_TTL = 900
60
+ _MAX_PENDING_AUTH = 2000
61
+
62
+
63
+ class TransientAuthError(RuntimeError):
64
+ """The Jingwei auth backend was *transiently* unavailable (network error,
65
+ timeout, or 5xx) during a token exchange.
66
+
67
+ Distinct from ``TokenError(invalid_grant)`` on purpose. The MCP SDK's token
68
+ handler only catches ``TokenError`` (serialized as HTTP 400), so this
69
+ propagates as an HTTP 500 instead. That difference matters to the client:
70
+
71
+ - ``invalid_grant`` means "your grant is permanently bad" → a well-behaved
72
+ client discards the refresh token and forces a full re-authorization.
73
+ - a 5xx means "server problem, try again" → the client retries and KEEPS its
74
+ refresh token.
75
+
76
+ Mapping a transient outage to ``invalid_grant`` would log everyone out during
77
+ exactly the redeploy window #833 is meant to make seamless, so transient
78
+ failures must never be reported as a bad grant.
79
+ """
80
+
81
+
82
+ # ── Persistent client store ──────────────────────────────────
83
+
84
+
85
+ def _load_clients() -> dict[str, dict[str, Any]]:
86
+ if _CLIENTS_FILE.exists():
87
+ try:
88
+ return json.loads(_CLIENTS_FILE.read_text())
89
+ except (json.JSONDecodeError, OSError):
90
+ return {}
91
+ return {}
92
+
93
+
94
+ def _save_clients(clients: dict[str, dict[str, Any]]) -> None:
95
+ _ensure_dir()
96
+ _write_private_file(_CLIENTS_FILE, json.dumps(clients, indent=2) + "\n")
97
+
98
+
99
+ # ── Extended models with user identity ───────────────────────
100
+
101
+
102
+ class DeepCellAuthorizationCode(AuthorizationCode):
103
+ """Authorization code carrying the authenticated user_id and the Jingwei
104
+ refresh token issued at login (reused at code-exchange time)."""
105
+
106
+ user_id: str
107
+ jingwei_refresh_token: str
108
+
109
+
110
+ class DeepCellAccessToken(AccessToken):
111
+ """Access token enriched with user identity from JWT claims."""
112
+
113
+ user_id: str
114
+
115
+
116
+ class DeepCellRefreshToken(RefreshToken):
117
+ """Refresh token wrapper. ``user_id`` is optional: when delegating to
118
+ Jingwei the user is resolved server-side from the token, so we don't always
119
+ know it locally (e.g. in ``load_refresh_token``)."""
120
+
121
+ user_id: str = ""
122
+
123
+
124
+ # ── Provider ─────────────────────────────────────────────────
125
+
126
+
127
+ class DeepCellOAuthProvider(
128
+ OAuthAuthorizationServerProvider[
129
+ DeepCellAuthorizationCode,
130
+ DeepCellRefreshToken,
131
+ DeepCellAccessToken,
132
+ ]
133
+ ):
134
+ """MCP OAuth 2.1 provider backed by Jingwei API for user auth."""
135
+
136
+ def __init__(
137
+ self,
138
+ *,
139
+ api_url: str,
140
+ server_url: str,
141
+ jwt_secret: str,
142
+ frontend_url: str | None = None,
143
+ jwt_algorithm: str = "HS256",
144
+ jwt_issuer: str = "deepcell-api",
145
+ jwt_audience: str = "deepcell-user",
146
+ ) -> None:
147
+ self.api_url = api_url.rstrip("/")
148
+ self.server_url = server_url.rstrip("/")
149
+ # Where the consent UI lives. Defaults to this server's own origin
150
+ # because nginx serves the Next.js frontend at `/` on the same host
151
+ # that proxies `/mcp` and `/oauth/*` here.
152
+ self.frontend_url = (frontend_url or server_url).rstrip("/")
153
+ self.jwt_secret = jwt_secret
154
+ self.jwt_algorithm = jwt_algorithm
155
+ self.jwt_issuer = jwt_issuer
156
+ self.jwt_audience = jwt_audience
157
+
158
+ # In-memory stores. Refresh tokens are NOT stored here — they are
159
+ # delegated to Jingwei (see ``_jingwei_refresh``).
160
+ self._clients: dict[str, dict[str, Any]] = _load_clients()
161
+ self._auth_codes: dict[str, DeepCellAuthorizationCode] = {}
162
+ # session_id -> (client, params, created_at). Pruned by _prune_pending.
163
+ self._pending_auth: dict[
164
+ str, tuple[OAuthClientInformationFull, AuthorizationParams, float]
165
+ ] = {}
166
+ self._api_key_cache: dict[str, tuple[DeepCellAccessToken, float]] = {}
167
+
168
+ # ── Client registration (DCR) ────────────────────────────
169
+
170
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
171
+ data = self._clients.get(client_id)
172
+ if data is None:
173
+ return None
174
+ return OAuthClientInformationFull.model_validate(data)
175
+
176
+ async def register_client(
177
+ self, client_info: OAuthClientInformationFull
178
+ ) -> None:
179
+ if client_info.client_id is None:
180
+ client_info.client_id = f"dc_{secrets.token_urlsafe(16)}"
181
+ if client_info.client_secret is None:
182
+ client_info.client_secret = secrets.token_urlsafe(32)
183
+ client_info.client_id_issued_at = int(time.time())
184
+
185
+ self._clients[client_info.client_id] = client_info.model_dump(
186
+ mode="json", exclude_none=True
187
+ )
188
+ _save_clients(self._clients)
189
+
190
+ # ── Authorization ────────────────────────────────────────
191
+
192
+ async def authorize(
193
+ self,
194
+ client: OAuthClientInformationFull,
195
+ params: AuthorizationParams,
196
+ ) -> str:
197
+ session_id = secrets.token_urlsafe(20)
198
+ self._pending_auth[session_id] = (client, params, time.time())
199
+ # Prune AFTER inserting so the cap holds including this entry, and so
200
+ # oldest-first eviction can never drop the session we just handed out.
201
+ self._prune_pending()
202
+ # Hand the browser to the shared consent page on the frontend, which
203
+ # renders the same AuthPanel as every other sign-in surface (Google
204
+ # included — this server can only check passwords, and OAuth-only
205
+ # accounts have none). The OAuth state machine stays here: the page
206
+ # comes back to POST /oauth/complete with an authorized device code.
207
+ return f"{self.frontend_url}/mcp-auth?session_id={quote(session_id, safe='')}"
208
+
209
+ def _prune_pending(self) -> None:
210
+ """Drop expired pending authorizations, then enforce the hard cap.
211
+
212
+ Called on every ``authorize()`` because that is the only unauthenticated
213
+ entry point that grows the map.
214
+ """
215
+ cutoff = time.time() - _PENDING_AUTH_TTL
216
+ for sid in [s for s, (_, _, ts) in self._pending_auth.items() if ts < cutoff]:
217
+ self._pending_auth.pop(sid, None)
218
+
219
+ # Still over cap after pruning: a burst inside one TTL window. Evict
220
+ # oldest-first (dicts preserve insertion order, and insertion order is
221
+ # creation order here) so a flood can't pin memory until restart.
222
+ overflow = len(self._pending_auth) - _MAX_PENDING_AUTH
223
+ if overflow > 0:
224
+ logger.warning(
225
+ "pending-authorization cap reached; evicting %d oldest", overflow
226
+ )
227
+ for sid in list(self._pending_auth)[:overflow]:
228
+ self._pending_auth.pop(sid, None)
229
+
230
+ def describe_pending(self, session_id: str) -> dict[str, Any] | None:
231
+ """Consent-screen metadata for a pending authorization.
232
+
233
+ Returns ``None`` when the session is unknown, expired, or already
234
+ consumed, so the page can show "expired" instead of a form that cannot
235
+ succeed.
236
+
237
+ ``redirect_host`` is the load-bearing field. Dynamic client registration
238
+ is open, so ``client_name`` and ``client_uri`` are free text chosen by
239
+ whoever registered the client — anyone can register one calling itself
240
+ "Claude.ai". The registered redirect target is the only value in here
241
+ the client cannot lie about, because the SDK sends the code there and
242
+ nowhere else, so it is what actually tells a user where their grant is
243
+ going.
244
+ """
245
+ pending = self._pending_auth.get(session_id)
246
+ if pending is None:
247
+ return None
248
+
249
+ client, params, created_at = pending
250
+ if time.time() - created_at > _PENDING_AUTH_TTL:
251
+ self._pending_auth.pop(session_id, None)
252
+ return None
253
+ redirect_host = ""
254
+ if params.redirect_uri:
255
+ parsed = urlparse(str(params.redirect_uri))
256
+ redirect_host = parsed.netloc or str(params.redirect_uri)
257
+
258
+ return {
259
+ "client_name": client.client_name or client.client_id or "",
260
+ "client_uri": str(client.client_uri) if client.client_uri else None,
261
+ "redirect_host": redirect_host,
262
+ "scopes": list(params.scopes or ["deepcell"]),
263
+ }
264
+
265
+ async def redeem_device_code(self, device_code: str) -> tuple[str, str] | None:
266
+ """Exchange an *authorized* device code for ``(user_id, refresh_token)``.
267
+
268
+ The consent page mints the code (``/auth/device/code``) and approves it
269
+ as the signed-in user (``/auth/device/authorize``); this redeems it. All
270
+ token minting therefore stays server-side in Jingwei's existing device
271
+ flow — no refresh token is ever handed to browser JavaScript, and the
272
+ user's own web session is left untouched rather than rotated out from
273
+ under them.
274
+
275
+ Returns ``None`` when the code is unknown, expired, or still pending.
276
+ """
277
+ try:
278
+ async with httpx.AsyncClient(timeout=10, headers=client_headers("mcp")) as client:
279
+ resp = await client.post(
280
+ f"{self.api_url}/auth/device/token",
281
+ json={"device_code": device_code},
282
+ )
283
+ except httpx.HTTPError as exc:
284
+ logger.error("Jingwei device-token request failed: %s", exc)
285
+ return None
286
+
287
+ if resp.status_code != 200:
288
+ logger.warning(
289
+ "Device-code redemption rejected (HTTP %s)", resp.status_code
290
+ )
291
+ return None
292
+
293
+ data = resp.json()
294
+ # A code that was never approved comes back 200 with {"status":
295
+ # "pending"} rather than an error, so this is a real branch, not
296
+ # defensive padding.
297
+ if data.get("status") == "pending":
298
+ return None
299
+
300
+ user_id = data.get("user", {}).get("id")
301
+ refresh_token = data.get("refresh_token")
302
+ if not user_id or not refresh_token:
303
+ logger.error("Device-token response missing user id or refresh token")
304
+ return None
305
+ return user_id, refresh_token
306
+
307
+ # ── Authorization code lifecycle ─────────────────────────
308
+
309
+ def create_authorization_code(
310
+ self,
311
+ session_id: str,
312
+ user_id: str,
313
+ jingwei_refresh_token: str,
314
+ ) -> tuple[str, str, str | None]:
315
+ """Called by the login callback after successful user authentication.
316
+
317
+ ``jingwei_refresh_token`` is the refresh token Jingwei issued during
318
+ login; it is carried on the (single-use, short-lived) auth code so that
319
+ ``exchange_authorization_code`` can rotate it into the tokens handed to
320
+ the MCP client.
321
+
322
+ Returns ``(code, redirect_uri, state)`` so the caller can redirect.
323
+ """
324
+ pending = self._pending_auth.pop(session_id, None)
325
+ if pending is None:
326
+ raise ValueError("Invalid or expired session")
327
+
328
+ client, params, created_at = pending
329
+ if time.time() - created_at > _PENDING_AUTH_TTL:
330
+ raise ValueError("Invalid or expired session")
331
+ code = secrets.token_urlsafe(20) # ≥160 bits entropy
332
+
333
+ self._auth_codes[code] = DeepCellAuthorizationCode(
334
+ code=code,
335
+ scopes=params.scopes or [],
336
+ expires_at=time.time() + _AUTH_CODE_TTL,
337
+ client_id=client.client_id or "",
338
+ code_challenge=params.code_challenge,
339
+ redirect_uri=params.redirect_uri,
340
+ redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
341
+ resource=params.resource,
342
+ user_id=user_id,
343
+ jingwei_refresh_token=jingwei_refresh_token,
344
+ )
345
+
346
+ return code, str(params.redirect_uri), params.state
347
+
348
+ async def load_authorization_code(
349
+ self,
350
+ client: OAuthClientInformationFull,
351
+ authorization_code: str,
352
+ ) -> DeepCellAuthorizationCode | None:
353
+ ac = self._auth_codes.get(authorization_code)
354
+ if ac is None:
355
+ return None
356
+ if time.time() > ac.expires_at:
357
+ self._auth_codes.pop(authorization_code, None)
358
+ return None
359
+ if ac.client_id != client.client_id:
360
+ return None
361
+ return ac
362
+
363
+ async def exchange_authorization_code(
364
+ self,
365
+ client: OAuthClientInformationFull,
366
+ authorization_code: DeepCellAuthorizationCode,
367
+ ) -> OAuthToken:
368
+ # Remove the code (single use)
369
+ self._auth_codes.pop(authorization_code.code, None)
370
+
371
+ # Rotate the login-issued Jingwei refresh token into the tokens we hand
372
+ # the MCP client. This immediately consumes the login token (so the copy
373
+ # stashed on the auth code can't be replayed) and returns a fresh,
374
+ # Postgres-backed access/refresh pair.
375
+ access_token, refresh_token, expires_in = await self._jingwei_refresh(
376
+ authorization_code.jingwei_refresh_token
377
+ )
378
+
379
+ scopes = authorization_code.scopes
380
+ return OAuthToken(
381
+ access_token=access_token,
382
+ token_type="Bearer",
383
+ expires_in=expires_in,
384
+ scope=" ".join(scopes) if scopes else None,
385
+ refresh_token=refresh_token,
386
+ )
387
+
388
+ # ── Token verification ───────────────────────────────────
389
+
390
+ async def load_access_token(self, token: str) -> DeepCellAccessToken | None:
391
+ # API key path — verify via Jingwei API
392
+ if token.startswith("dck_"):
393
+ return await self._verify_api_key(token)
394
+
395
+ # JWT path
396
+ if not self.jwt_secret:
397
+ return None
398
+ try:
399
+ payload = pyjwt.decode(
400
+ token,
401
+ self.jwt_secret,
402
+ algorithms=[self.jwt_algorithm],
403
+ issuer=self.jwt_issuer,
404
+ audience=self.jwt_audience,
405
+ )
406
+ if payload.get("type") != "access":
407
+ return None
408
+ return DeepCellAccessToken(
409
+ token=token,
410
+ client_id="", # Not tracked per-token
411
+ scopes=["deepcell"],
412
+ expires_at=payload.get("exp"),
413
+ user_id=payload["sub"],
414
+ )
415
+ except (pyjwt.ExpiredSignatureError, pyjwt.InvalidTokenError):
416
+ return None
417
+
418
+ async def _verify_api_key(self, token: str) -> DeepCellAccessToken | None:
419
+ """Verify a ``dck_`` API key via the Jingwei ``/auth/api-keys/verify``
420
+ endpoint. Results are cached in-memory for 60 seconds."""
421
+ import hashlib
422
+ import os
423
+
424
+ cache_key = hashlib.sha256(token.encode()).hexdigest()
425
+ now = time.time()
426
+
427
+ # Check cache
428
+ cached = self._api_key_cache.get(cache_key)
429
+ if cached and now - cached[1] < 60:
430
+ return cached[0]
431
+
432
+ # Call Jingwei API
433
+ jingwei_api_key = os.environ.get("JINGWEI_API_KEY", "")
434
+ headers: dict[str, str] = {}
435
+ if jingwei_api_key:
436
+ headers["Authorization"] = f"Bearer {jingwei_api_key}"
437
+
438
+ try:
439
+ async with httpx.AsyncClient(timeout=10, headers=client_headers("mcp")) as client:
440
+ resp = await client.post(
441
+ f"{self.api_url}/auth/api-keys/verify",
442
+ json={"key": token},
443
+ headers=headers,
444
+ )
445
+ if resp.status_code == 200:
446
+ data = resp.json()
447
+ result = DeepCellAccessToken(
448
+ token=token,
449
+ client_id="api-key",
450
+ scopes=data.get("scopes", ["deepcell"]),
451
+ expires_at=None,
452
+ user_id=data["user_id"],
453
+ )
454
+ self._api_key_cache[cache_key] = (result, now)
455
+ return result
456
+ except httpx.HTTPError as exc:
457
+ logger.error("API key verification request failed: %s", exc)
458
+
459
+ return None
460
+
461
+ # ── Refresh token lifecycle ──────────────────────────────
462
+
463
+ async def load_refresh_token(
464
+ self,
465
+ client: OAuthClientInformationFull,
466
+ refresh_token: str,
467
+ ) -> DeepCellRefreshToken | None:
468
+ # There is no local refresh-token store — the token is a Jingwei refresh
469
+ # token, validated server-side at rotation time (see _jingwei_refresh).
470
+ # Return an optimistic wrapper bound to the requesting client so the SDK
471
+ # token handler's pre-exchange checks (client_id match, expiry, scope
472
+ # subset) pass; Jingwei is the real authority on validity.
473
+ return DeepCellRefreshToken(
474
+ token=refresh_token,
475
+ client_id=client.client_id or "",
476
+ scopes=["deepcell"],
477
+ expires_at=None,
478
+ )
479
+
480
+ async def exchange_refresh_token(
481
+ self,
482
+ client: OAuthClientInformationFull,
483
+ refresh_token: DeepCellRefreshToken,
484
+ scopes: list[str],
485
+ ) -> OAuthToken:
486
+ # Delegate rotation to Jingwei (Postgres-backed, with token-family
487
+ # reuse-detection). Raises TokenError(invalid_grant) if the token is
488
+ # rejected, or TransientAuthError if the backend is unreachable.
489
+ access_token, new_refresh, expires_in = await self._jingwei_refresh(
490
+ refresh_token.token
491
+ )
492
+
493
+ return OAuthToken(
494
+ access_token=access_token,
495
+ token_type="Bearer",
496
+ expires_in=expires_in,
497
+ scope=" ".join(scopes) if scopes else None,
498
+ refresh_token=new_refresh,
499
+ )
500
+
501
+ # ── Revocation ───────────────────────────────────────────
502
+
503
+ async def revoke_token(
504
+ self,
505
+ token: DeepCellAccessToken | DeepCellRefreshToken,
506
+ ) -> None:
507
+ # A refresh token *is* a Jingwei refresh token, so an explicit OAuth
508
+ # revocation (e.g. a connector being disconnected) is delegated to
509
+ # Jingwei's ``/auth/revoke-token`` endpoint, which invalidates the
510
+ # underlying session in Postgres. Best-effort: per RFC 7009 the client's
511
+ # revoke request should succeed regardless, so transport/HTTP failures
512
+ # are logged and swallowed rather than surfaced. Access tokens are
513
+ # stateless short-lived JWTs that cannot be revoked without a blocklist,
514
+ # so they simply expire (15 min).
515
+ if isinstance(token, DeepCellRefreshToken):
516
+ try:
517
+ async with httpx.AsyncClient(timeout=10, headers=client_headers("mcp")) as client:
518
+ await client.post(
519
+ f"{self.api_url}/auth/revoke-token",
520
+ json={"refresh_token": token.token},
521
+ )
522
+ except httpx.HTTPError as exc:
523
+ logger.warning("Jingwei revoke request failed (best-effort): %s", exc)
524
+ return None
525
+
526
+ # ── Internal helpers ─────────────────────────────────────
527
+
528
+ async def _jingwei_refresh(self, raw_refresh_token: str) -> tuple[str, str, int]:
529
+ """Rotate a Jingwei refresh token via the API's ``/auth/refresh`` endpoint.
530
+
531
+ An MCP refresh token *is* a Jingwei refresh token, so rotation,
532
+ token-family reuse-detection and revocation all live in Postgres and
533
+ survive container recreates. ``/auth/refresh`` accepts the token in the
534
+ request body (``RefreshRequest.refresh_token``).
535
+
536
+ Returns ``(access_token, refresh_token, expires_in)``.
537
+
538
+ Error mapping distinguishes a *bad grant* from a *transient outage* so a
539
+ backend blip doesn't log clients out (see ``TransientAuthError``):
540
+
541
+ Raises:
542
+ TokenError(``invalid_grant``): Jingwei rejected the token with a 4xx
543
+ (invalid / expired / reused) — the grant is genuinely bad, so the
544
+ client should re-authorize.
545
+ TransientAuthError: the backend was unreachable, timed out, returned
546
+ a 5xx, or returned 200 without tokens (a contract violation). The
547
+ grant may still be valid, so the client should retry and keep it.
548
+ """
549
+ try:
550
+ async with httpx.AsyncClient(timeout=10, headers=client_headers("mcp")) as client:
551
+ resp = await client.post(
552
+ f"{self.api_url}/auth/refresh",
553
+ json={"refresh_token": raw_refresh_token},
554
+ )
555
+ except httpx.HTTPError as exc:
556
+ logger.error("Jingwei refresh request failed (transient): %s", exc)
557
+ raise TransientAuthError("Auth service unreachable") from exc
558
+
559
+ if resp.status_code == 200:
560
+ data = resp.json()
561
+ access_token = data.get("access_token")
562
+ new_refresh = data.get("refresh_token")
563
+ if not access_token or not new_refresh:
564
+ # 200 but no tokens is a backend bug, not a rejected grant —
565
+ # don't force the client to re-auth over it.
566
+ logger.error("Jingwei refresh response missing tokens")
567
+ raise TransientAuthError("Auth service returned no tokens")
568
+ return access_token, new_refresh, int(data.get("expires_in", 900))
569
+
570
+ if 400 <= resp.status_code < 500:
571
+ logger.info("Jingwei rejected refresh token (status %s)", resp.status_code)
572
+ raise TokenError(
573
+ error="invalid_grant",
574
+ error_description="Refresh token is invalid or expired",
575
+ )
576
+
577
+ # 5xx (and anything else non-2xx): transient backend failure.
578
+ logger.error("Jingwei refresh failed transiently (status %s)", resp.status_code)
579
+ raise TransientAuthError(f"Auth service error (status {resp.status_code})")
580
+