zjcode 0.0.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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1950 @@
1
+ """OAuth login flow and token storage for MCP servers.
2
+
3
+ Note: `mcp.shared.auth.OAuthToken` is a pydantic model whose default
4
+ `repr` includes the access and refresh token strings verbatim. Never
5
+ log one via `%r`, `str()`, f-string interpolation, or
6
+ `logger.exception`/`exc_info` on an exception that wraps one — the
7
+ tokens will land in stdout, log files, and error-reporting
8
+ pipelines. Pass only structural facts ("refreshed token for
9
+ server X") rather than the token itself.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import concurrent.futures
16
+ import contextlib
17
+ import contextvars
18
+ import hashlib
19
+ import html
20
+ import json
21
+ import logging
22
+ import os
23
+ import re
24
+ import secrets
25
+ import stat
26
+ import threading
27
+ import time
28
+ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
29
+ from typing import TYPE_CHECKING, Any, Literal, TypedDict
30
+ from urllib.parse import parse_qs, urlparse
31
+
32
+ import httpx
33
+ from filelock import FileLock, Timeout
34
+ from mcp.client.auth import OAuthClientProvider, TokenStorage
35
+ from mcp.client.auth.utils import (
36
+ build_oauth_authorization_server_metadata_discovery_urls,
37
+ build_protected_resource_metadata_discovery_urls,
38
+ create_oauth_metadata_request,
39
+ handle_auth_metadata_response,
40
+ handle_protected_resource_response,
41
+ )
42
+ from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
43
+ from mcp.shared.auth import (
44
+ OAuthClientInformationFull,
45
+ OAuthMetadata,
46
+ OAuthToken,
47
+ )
48
+ from pydantic import BaseModel, ConfigDict, ValidationError
49
+ from typing_extensions import override
50
+
51
+ from deepagents_code.mcp_config import resolve_mcp_server_env
52
+
53
+ if TYPE_CHECKING:
54
+ from pathlib import Path
55
+
56
+ from mcp.client.auth.oauth2 import OAuthContext
57
+
58
+ from deepagents_code.mcp_oauth_ui import OAuthInteraction
59
+
60
+
61
+ class _DeviceCodeResponse(BaseModel):
62
+ """RFC 8628 §3.2 device-authorization response payload."""
63
+
64
+ model_config = ConfigDict(extra="ignore")
65
+
66
+ device_code: str
67
+ """Opaque device code the client polls with at the token endpoint."""
68
+
69
+ user_code: str
70
+ """Short code the user enters in the browser to approve the device."""
71
+
72
+ verification_uri: str
73
+ """Provider URL the user visits to complete device authorization."""
74
+
75
+ expires_in: int
76
+ """Lifetime of the device code in seconds."""
77
+
78
+ interval: int = 5
79
+ """Recommended polling interval in seconds when the provider omits one."""
80
+
81
+
82
+ class McpServerSpec(TypedDict, total=False):
83
+ """Parsed MCP server config entry.
84
+
85
+ All keys are optional at the type level because `mcpServers` entries
86
+ are validated shape-first by `_validate_server_config` rather than by
87
+ the type system. This TypedDict documents the accepted shape for
88
+ readers and static checkers — validate the fields at use sites before
89
+ relying on them.
90
+ """
91
+
92
+ auth: Literal["oauth"]
93
+ """Authentication mode for remote MCP servers that require OAuth login."""
94
+
95
+ type: Literal["stdio", "http", "sse"]
96
+ """Transport type when the config uses the `type` key."""
97
+
98
+ transport: Literal["stdio", "http", "sse"]
99
+ """Transport type when the config uses the `transport` key."""
100
+
101
+ url: str
102
+ """Remote endpoint URL for HTTP or SSE MCP servers."""
103
+
104
+ headers: dict[str, str]
105
+ """Optional request headers sent when connecting to the remote server."""
106
+
107
+ command: str
108
+ """Executable for stdio MCP servers."""
109
+
110
+ args: list[str]
111
+ """Command-line arguments passed to the stdio server executable."""
112
+
113
+ env: dict[str, str]
114
+ """Environment overrides for launching a stdio MCP server."""
115
+
116
+
117
+ logger = logging.getLogger(__name__)
118
+ _SUPPRESS_EXPECTED_REAUTH_LOGS: contextvars.ContextVar[bool] = contextvars.ContextVar(
119
+ "suppress_expected_mcp_reauth_logs",
120
+ default=False,
121
+ )
122
+
123
+
124
+ _TOKEN_REFRESH_FAILED_PREFIX = "Token refresh failed: " # noqa: S105 # log-message prefix, not a credential
125
+ """Prefix of the SDK's `Token refresh failed: <status>` warning (`oauth2.py`)."""
126
+
127
+ _EXPECTED_REAUTH_REFRESH_STATUSES = frozenset({"400", "401", "403"})
128
+ """Refresh-endpoint statuses that mean the grant was rejected (token stale).
129
+
130
+ The SDK logs `Token refresh failed: <status>` and clears tokens for *any*
131
+ non-200 on the refresh endpoint. Only these statuses indicate the refresh
132
+ token itself is expired/revoked — i.e. the expected re-auth cases our hint
133
+ replaces. Transient failures (`429`, `5xx`, gateway timeouts) must stay
134
+ visible so a provider outage isn't silently relabeled as "go re-login".
135
+ """
136
+ _EXPECTED_REAUTH_REFRESH_STATUS_CODES = frozenset(
137
+ int(status) for status in _EXPECTED_REAUTH_REFRESH_STATUSES
138
+ )
139
+
140
+
141
+ class _ExpectedReauthLogFilter(logging.Filter):
142
+ """Drop SDK OAuth log records that are replaced by our reauth hint."""
143
+
144
+ @override
145
+ def filter(self, record: logging.LogRecord) -> bool:
146
+ """Return whether the SDK OAuth log record should be emitted."""
147
+ if not _SUPPRESS_EXPECTED_REAUTH_LOGS.get():
148
+ return True
149
+ message = record.getMessage()
150
+ if message.startswith(_TOKEN_REFRESH_FAILED_PREFIX):
151
+ status = message.removeprefix(_TOKEN_REFRESH_FAILED_PREFIX)
152
+ if status in _EXPECTED_REAUTH_REFRESH_STATUSES:
153
+ return False
154
+ if message == "OAuth flow error" and record.exc_info is not None:
155
+ exc = record.exc_info[1]
156
+ if exc is not None and find_reauth_required(exc) is not None:
157
+ return False
158
+ return True
159
+
160
+
161
+ logging.getLogger("mcp.client.auth.oauth2").addFilter(_ExpectedReauthLogFilter())
162
+
163
+ _SAFE_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
164
+ """Matches server names that are safe to embed in token-file basenames.
165
+
166
+ Mirrors `_SERVER_NAME_RE` in `mcp_tools` — duplicated here because
167
+ `mcp_auth` cannot import from `mcp_tools` at module top-level without
168
+ risking a circular import. Keep both regexes in sync."""
169
+
170
+ _STORAGE_VERSION = 1
171
+ """Schema version stamped into persisted credential files; bump on incompatible
172
+ shape changes so `_load_*` can reject or migrate older payloads."""
173
+
174
+ _REFRESH_SAFETY_MARGIN_SECONDS = 30.0
175
+ """Refresh access tokens this many seconds before their advertised expiry.
176
+
177
+ Absorbs clock skew and request latency so a token deemed valid locally isn't
178
+ rejected as expired by the server — without the margin, a 401 sends the SDK
179
+ into the full re-auth (browser) flow instead of the cheaper refresh grant.
180
+ """
181
+
182
+
183
+ def resolve_headers(
184
+ headers: dict[str, str],
185
+ *,
186
+ server_name: str | None = None,
187
+ ) -> dict[str, str]:
188
+ """Resolve environment-variable references in MCP header values.
189
+
190
+ This compatibility wrapper preserves the original public helper while
191
+ delegating interpolation and validation to the shared MCP config resolver.
192
+
193
+ Args:
194
+ headers: Raw header mapping from MCP config.
195
+ server_name: Optional server name for field-specific error messages.
196
+
197
+ Returns:
198
+ A new dictionary with environment-variable references resolved.
199
+
200
+ Raises:
201
+ TypeError: If a header value is not a string.
202
+ RuntimeError: If interpolation fails.
203
+ """ # noqa: DOC502 - `RuntimeError` is raised by the shared config resolver
204
+ resolved = resolve_mcp_server_env(
205
+ server_name or "<unknown>",
206
+ {"headers": headers},
207
+ )
208
+ return resolved["headers"]
209
+
210
+
211
+ _REFRESH_LOCK_TIMEOUT_SECONDS = 60.0
212
+ """Longest a provider waits for the cross-process token-refresh lock.
213
+
214
+ Bounds the wait so a live-but-stuck peer (e.g. one whose refresh network call
215
+ hangs) can't block tool calls indefinitely. A peer that outright crashes is not
216
+ the concern: the OS releases an `fcntl` lock when the holding process exits. On
217
+ timeout the provider reloads tokens from disk and avoids using any still-stale
218
+ refresh token (see `_acquire_refresh_lock`).
219
+ """
220
+
221
+
222
+ def _tokens_dir() -> Path:
223
+ """Return `~/.deepagents/.state/mcp-tokens/`.
224
+
225
+ The deferred import lets tests redirect token storage into a temp
226
+ directory by patching `deepagents_code.model_config.DEFAULT_STATE_DIR`.
227
+ """
228
+ from deepagents_code.model_config import DEFAULT_STATE_DIR
229
+
230
+ return DEFAULT_STATE_DIR / "mcp-tokens"
231
+
232
+
233
+ def _token_file_stem(server_name: str, server_url: str | None) -> str:
234
+ """Return a path-safe storage stem for this server identity.
235
+
236
+ Safety of the stem depends on `server_name` already having passed
237
+ `_SERVER_NAME_RE` in `_validate_server_config` — the URL is hashed
238
+ to a hex digest, so only the server name can carry path separators.
239
+ """
240
+ if server_url is None:
241
+ return server_name
242
+ digest = hashlib.sha256(server_url.encode("utf-8")).hexdigest()[:16]
243
+ return f"{server_name}-{digest}"
244
+
245
+
246
+ class FileTokenStorage(TokenStorage):
247
+ """File-backed `TokenStorage` under `~/.deepagents/.state/mcp-tokens/`."""
248
+
249
+ def __init__(self, server_name: str, *, server_url: str | None = None) -> None:
250
+ """Bind this storage to a configured MCP server identity.
251
+
252
+ Raises:
253
+ ValueError: If `server_name` contains characters that would let
254
+ it escape the `~/.deepagents/.state/mcp-tokens/` directory
255
+ when used as the token-file basename.
256
+ """
257
+ if not _SAFE_SERVER_NAME_RE.fullmatch(server_name):
258
+ msg = (
259
+ f"Invalid MCP server name {server_name!r}: token storage "
260
+ "names must match [A-Za-z0-9_-]+ to keep the on-disk path "
261
+ "inside ~/.deepagents/.state/mcp-tokens/."
262
+ )
263
+ raise ValueError(msg)
264
+ self._server_name = server_name
265
+ self._server_url = server_url
266
+
267
+ @property
268
+ def path(self) -> Path:
269
+ """On-disk token file path for this server."""
270
+ stem = _token_file_stem(self._server_name, self._server_url)
271
+ return _tokens_dir() / f"{stem}.json"
272
+
273
+ @property
274
+ def refresh_lock_path(self) -> Path:
275
+ """Sibling lock file that serializes token refreshes across processes.
276
+
277
+ A dedicated `.lock` file (never the token file itself) lets `filelock`
278
+ coordinate refreshes between dcode processes and provider instances
279
+ without ever holding an exclusive lock on the credential file. It holds
280
+ no token material.
281
+ """
282
+ path = self.path
283
+ return path.with_name(f"{path.name}.lock")
284
+
285
+ async def get_tokens(self) -> OAuthToken | None:
286
+ """Return the stored `OAuthToken`, or `None` if none is persisted."""
287
+ data = self._read()
288
+ if data is None:
289
+ return None
290
+ raw = data.get("tokens")
291
+ if raw is None:
292
+ return None
293
+ return OAuthToken.model_validate(raw)
294
+
295
+ async def set_tokens(self, tokens: OAuthToken) -> None:
296
+ """Persist `tokens` to disk, preserving any stored client info.
297
+
298
+ Also records the absolute Unix-epoch expiry as a sidecar so a
299
+ cold-started provider can detect a stale access token and trigger
300
+ the SDK's `refresh_token` grant instead of a full browser re-auth.
301
+ Cleared when `expires_in` is absent so the sidecar can't go stale.
302
+ """
303
+ data = self._read() or {}
304
+ data["version"] = _STORAGE_VERSION
305
+ data["tokens"] = json.loads(tokens.model_dump_json(exclude_none=True))
306
+ if tokens.expires_in is not None:
307
+ data["expires_at"] = time.time() + tokens.expires_in
308
+ else:
309
+ data.pop("expires_at", None)
310
+ self._write(data)
311
+
312
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
313
+ """Return the stored client registration, or `None` if none is persisted."""
314
+ data = self._read()
315
+ if data is None:
316
+ return None
317
+ raw = data.get("client_info")
318
+ if raw is None:
319
+ return None
320
+ return OAuthClientInformationFull.model_validate(raw)
321
+
322
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
323
+ """Persist `client_info` to disk, preserving any stored tokens."""
324
+ data = self._read() or {}
325
+ data["version"] = _STORAGE_VERSION
326
+ data["client_info"] = json.loads(client_info.model_dump_json(exclude_none=True))
327
+ self._write(data)
328
+
329
+ async def get_oauth_metadata(self) -> OAuthMetadata | None:
330
+ """Return stored public OAuth authorization metadata, if available."""
331
+ data = self._read()
332
+ if data is None:
333
+ return None
334
+ raw = data.get("oauth_metadata")
335
+ if raw is None:
336
+ return None
337
+ return OAuthMetadata.model_validate(raw)
338
+
339
+ async def set_oauth_metadata(self, metadata: OAuthMetadata) -> None:
340
+ """Persist public OAuth authorization metadata beside the token state."""
341
+ data = self._read() or {}
342
+ data["version"] = _STORAGE_VERSION
343
+ data["oauth_metadata"] = json.loads(metadata.model_dump_json(exclude_none=True))
344
+ self._write(data)
345
+
346
+ async def set_tokens_and_client_info(
347
+ self,
348
+ tokens: OAuthToken,
349
+ client_info: OAuthClientInformationFull,
350
+ ) -> None:
351
+ """Persist tokens and client info in a single atomic write.
352
+
353
+ Prevents the state where one call succeeds and the other fails,
354
+ leaving an orphan on disk.
355
+ """
356
+ data = self._read() or {}
357
+ data["version"] = _STORAGE_VERSION
358
+ data["tokens"] = json.loads(tokens.model_dump_json(exclude_none=True))
359
+ data["client_info"] = json.loads(client_info.model_dump_json(exclude_none=True))
360
+ if tokens.expires_in is not None:
361
+ data["expires_at"] = time.time() + tokens.expires_in
362
+ else:
363
+ data.pop("expires_at", None)
364
+ self._write(data)
365
+
366
+ async def get_expires_at(self) -> float | None:
367
+ """Return the stored absolute token expiry (Unix epoch), or `None`.
368
+
369
+ Returns `None` for token files written before this field existed,
370
+ for tokens whose provider omitted `expires_in`, or when the sidecar
371
+ value fails to coerce to `float`. Callers should treat `None` as
372
+ "expiry unknown" and decide policy (skip, assume-expired, etc.).
373
+ """
374
+ data = self._read()
375
+ if data is None:
376
+ return None
377
+ raw = data.get("expires_at")
378
+ if raw is None:
379
+ return None
380
+ try:
381
+ return float(raw)
382
+ except (TypeError, ValueError):
383
+ # Log the value's type, never the value itself — the sidecar lives
384
+ # next to the bearer token in the same JSON envelope, so a
385
+ # misplaced token string would land here.
386
+ logger.warning(
387
+ "MCP token sidecar 'expires_at' for %s is not numeric (%s); "
388
+ "treating as unknown. The next request will trigger a refresh "
389
+ "or browser re-auth.",
390
+ self._server_name,
391
+ type(raw).__name__,
392
+ )
393
+ return None
394
+
395
+ def stored_loopback_port(self) -> int | None:
396
+ """Return the stored loopback redirect URI port, if one is reusable.
397
+
398
+ DCR registers `client_id` against a specific `redirect_uri`. If the
399
+ callback server binds a fresh random port on a later launch, the
400
+ authorize request will carry a `redirect_uri` that no longer matches
401
+ the one registered with the persisted `client_id`, and the
402
+ authorization server will reject it ("invalid or missing redirect_uri").
403
+ Reusing the persisted port keeps the registration valid across runs.
404
+
405
+ Returns:
406
+ The integer port parsed from a stored
407
+ `http://localhost:<port>/callback` redirect URI, or `None` if
408
+ no usable port is on disk.
409
+ """
410
+ try:
411
+ data = self._read()
412
+ except RuntimeError as exc:
413
+ logger.warning(
414
+ "MCP token file for %s is unreadable during loopback port "
415
+ "lookup; falling back to a fresh random port. Delete the file "
416
+ "and log in again if OAuth authorization fails: %s",
417
+ self.path,
418
+ exc,
419
+ )
420
+ return None
421
+ if data is None:
422
+ return None
423
+ client_info = data.get("client_info") or {}
424
+ redirect_uris = client_info.get("redirect_uris") or []
425
+ if not redirect_uris:
426
+ return None
427
+ uri = str(redirect_uris[0])
428
+ port = self._loopback_callback_port(uri)
429
+ if port is None:
430
+ logger.warning(
431
+ "Stored MCP OAuth redirect URI for %s is not a reusable "
432
+ "loopback callback URI; falling back to a fresh random port. "
433
+ "OAuth authorization may fail if the server requires the "
434
+ "persisted client registration redirect URI: %s",
435
+ self.path,
436
+ uri,
437
+ )
438
+ return None
439
+ return port
440
+
441
+ @staticmethod
442
+ def _loopback_callback_port(uri: str) -> int | None:
443
+ """Return `uri`'s port if it is a reusable loopback callback URI.
444
+
445
+ A reusable URI is `http://localhost:<port>/callback` with an explicit
446
+ port. Anything else (a different scheme/host/path, or a portless
447
+ `http://localhost/callback`) returns `None` because it cannot be paired
448
+ with the loopback callback server a CLI login binds.
449
+ """
450
+ parsed = urlparse(uri)
451
+ try:
452
+ port = parsed.port
453
+ except ValueError:
454
+ return None
455
+ if (
456
+ parsed.scheme != "http"
457
+ or parsed.hostname != _LOOPBACK_URI_HOST
458
+ or parsed.path != _LOOPBACK_CALLBACK_PATH
459
+ or port is None
460
+ ):
461
+ return None
462
+ return port
463
+
464
+ def discard_client_info_if_loopback_unusable(self) -> bool:
465
+ """Drop a persisted client registration that can't serve loopback login.
466
+
467
+ `stored_loopback_port` explains why the authorize request's
468
+ `redirect_uri` must match the one registered against the persisted
469
+ `client_id`. When that registered URI is not a reusable loopback
470
+ callback (e.g. a portless `http://localhost/callback` left by an earlier
471
+ non-loopback login), no port can be reused, so a CLI login binds a fresh
472
+ random port and the server rejects the mismatched `redirect_uri`
473
+ ("invalid or missing redirect_uri"). Removing the stale `client_info`
474
+ makes the SDK perform a fresh DCR with the loopback redirect URI it will
475
+ actually use.
476
+
477
+ Only discards when no token is persisted at all, so a session with a
478
+ usable (or refreshable) token is never downgraded to a full re-auth.
479
+ The presence check is deliberately conservative: it errs toward keeping
480
+ the registration, never toward deleting one that might still be needed.
481
+
482
+ Returns:
483
+ `True` if a stale client registration was removed. `False` covers
484
+ both "nothing to discard" and "could not discard" (an
485
+ unreadable or unwritable token file, logged where it happens).
486
+ """
487
+ try:
488
+ data = self._read()
489
+ except RuntimeError as exc:
490
+ # Mirror `stored_loopback_port`: a corrupt or unsupported-version
491
+ # token file carries actionable "delete the file" guidance in the
492
+ # `RuntimeError` message, so surface it rather than dropping it.
493
+ logger.warning(
494
+ "MCP token file for %s is unreadable while checking for a "
495
+ "stale client registration; skipping self-heal. Delete the "
496
+ "file and log in again if OAuth authorization fails: %s",
497
+ self.path,
498
+ exc,
499
+ )
500
+ return False
501
+ if data is None or "client_info" not in data:
502
+ return False
503
+ # A persisted access/refresh token can still authenticate (or refresh)
504
+ # without re-running the authorization-code grant, so leave the
505
+ # registration intact rather than forcing an avoidable re-auth.
506
+ if data.get("tokens") is not None:
507
+ return False
508
+ redirect_uris = (data.get("client_info") or {}).get("redirect_uris") or []
509
+ if (
510
+ redirect_uris
511
+ and self._loopback_callback_port(str(redirect_uris[0])) is not None
512
+ ):
513
+ return False
514
+ del data["client_info"]
515
+ try:
516
+ self._write(data)
517
+ except OSError as exc:
518
+ # Surface the failure but don't crash login: the stale registration
519
+ # simply remains, and the login attempt fails the same way it would
520
+ # have without this self-heal.
521
+ logger.warning(
522
+ "Could not remove stale MCP client registration in %s: %s",
523
+ self.path,
524
+ exc,
525
+ )
526
+ return False
527
+ return True
528
+
529
+ def _read(self) -> dict | None:
530
+ path = self.path
531
+ if not path.exists():
532
+ return None
533
+ try:
534
+ raw = path.read_text(encoding="utf-8")
535
+ data = json.loads(raw)
536
+ except (OSError, json.JSONDecodeError) as exc:
537
+ msg = (
538
+ f"Failed to read MCP token file {path}: {exc}. "
539
+ f"Delete the file and run `/mcp login {self._server_name}` "
540
+ f"in the TUI (or `dcode mcp login {self._server_name}`)."
541
+ )
542
+ raise RuntimeError(msg) from exc
543
+ if data.get("version") != _STORAGE_VERSION:
544
+ msg = (
545
+ f"MCP token file {path} has unsupported version "
546
+ f"{data.get('version')!r} (expected {_STORAGE_VERSION}). "
547
+ f"Delete it and run `/mcp login {self._server_name}` in the "
548
+ f"TUI (or `dcode mcp login {self._server_name}`)."
549
+ )
550
+ raise RuntimeError(msg)
551
+ return data
552
+
553
+ def _write(self, data: dict) -> None:
554
+ path = self.path
555
+ path.parent.mkdir(parents=True, exist_ok=True)
556
+ if hasattr(os, "chmod"):
557
+ try:
558
+ path.parent.chmod(stat.S_IRWXU)
559
+ except OSError as exc:
560
+ # A failing chmod on the parent dir leaves the tokens
561
+ # directory at the default umask. Warn so operators on
562
+ # shared hosts notice.
563
+ logger.warning(
564
+ "Could not lock down MCP tokens dir %s (mode 0700): %s. "
565
+ "Tokens may be readable by other local users.",
566
+ path.parent,
567
+ exc,
568
+ )
569
+ tmp = path.with_suffix(path.suffix + ".tmp")
570
+ payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
571
+ # O_EXCL + mode 0600 means the token file is never visible at the
572
+ # default umask between open() and chmod(). On Windows, os.open()
573
+ # ignores the mode bits, so the explicit chmod below is the
574
+ # cross-platform guarantee.
575
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
576
+ with contextlib.suppress(FileNotFoundError):
577
+ tmp.unlink()
578
+ fd = os.open(str(tmp), flags, 0o600)
579
+ try:
580
+ with os.fdopen(fd, "wb") as fh:
581
+ fh.write(payload)
582
+ except Exception:
583
+ with contextlib.suppress(OSError):
584
+ tmp.unlink()
585
+ raise
586
+ try:
587
+ tmp.replace(path)
588
+ except Exception:
589
+ with contextlib.suppress(OSError):
590
+ tmp.unlink()
591
+ raise
592
+ if hasattr(os, "chmod"):
593
+ # Already 0600 from os.open on POSIX; a second chmod covers
594
+ # filesystems that ignore the create-mode argument.
595
+ try:
596
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
597
+ except OSError as exc:
598
+ logger.warning(
599
+ "Could not set mode 0600 on MCP token file %s: %s. "
600
+ "Stored refresh/access tokens may be world-readable.",
601
+ path,
602
+ exc,
603
+ )
604
+
605
+
606
+ RedirectHandler = Callable[[str], Awaitable[None]]
607
+ CallbackHandler = Callable[[], Awaitable[tuple[str, str | None]]]
608
+ _LOOPBACK_BIND_HOST = "127.0.0.1"
609
+ _LOOPBACK_URI_HOST = "localhost"
610
+ _LOOPBACK_CALLBACK_PATH = "/callback"
611
+ _LOOPBACK_CALLBACK_TIMEOUT = 300.0
612
+
613
+
614
+ class _LoopbackCallbackTimeoutError(RuntimeError):
615
+ """Raised when the browser never reaches the local callback server."""
616
+
617
+
618
+ class _LoopbackCallbackUnavailableError(RuntimeError):
619
+ """Raised when the local callback server cannot be started."""
620
+
621
+
622
+ def _choose_loopback_port() -> int:
623
+ """Return a high local TCP port candidate without opening a socket.
624
+
625
+ The OAuth redirect URI must be known before the provider starts the
626
+ handshake, but the actual callback server should not keep a socket open
627
+ unless a browser redirect is needed.
628
+
629
+ Returns:
630
+ A port number from the dynamic/private port range.
631
+ """
632
+ return 49152 + secrets.randbelow(65535 - 49152 + 1)
633
+
634
+
635
+ class _LoopbackOAuthCallbackServer:
636
+ """Single-use loopback HTTP server for CLI OAuth callbacks.
637
+
638
+ Port selection and socket binding are intentionally separated: the
639
+ redirect URI is fixed at construction time so it can be registered with
640
+ the OAuth provider, while the socket is not opened until `start()` is
641
+ called from the redirect handler.
642
+ """
643
+
644
+ def __init__(self, *, port: int) -> None:
645
+ """Prepare a callback server for a previously selected loopback port.
646
+
647
+ Args:
648
+ port: TCP port to bind when `start()` is called.
649
+ """
650
+ self._port = port
651
+ self.redirect_uri = (
652
+ f"http://{_LOOPBACK_URI_HOST}:{port}{_LOOPBACK_CALLBACK_PATH}"
653
+ )
654
+ self._future: concurrent.futures.Future[tuple[str, str | None]] = (
655
+ concurrent.futures.Future()
656
+ )
657
+ self._server: object | None = None
658
+ self._started = False
659
+ self._closed = False
660
+ self._thread: threading.Thread | None = None
661
+
662
+ @property
663
+ def port(self) -> int:
664
+ """Loopback TCP port this server will bind on `start()`."""
665
+ return self._port
666
+
667
+ def start(self) -> None:
668
+ """Bind and start serving callback requests in a background thread."""
669
+ if self._started:
670
+ return
671
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
672
+
673
+ parent = self
674
+
675
+ class Handler(BaseHTTPRequestHandler):
676
+ def do_GET(self) -> None:
677
+ parent._handle_get(self)
678
+
679
+ def log_message( # noqa: PLR6301 # stdlib override
680
+ self,
681
+ format: str, # noqa: A002
682
+ *args: object,
683
+ ) -> None:
684
+ del format, args
685
+
686
+ self._server = ThreadingHTTPServer((_LOOPBACK_BIND_HOST, self._port), Handler)
687
+ self._started = True
688
+ self._thread = threading.Thread(
689
+ target=self._serve_forever,
690
+ name="deepagents-mcp-oauth-callback",
691
+ daemon=True,
692
+ )
693
+ self._thread.start()
694
+
695
+ def _serve_forever(self) -> None:
696
+ from http.server import ThreadingHTTPServer
697
+
698
+ if isinstance(self._server, ThreadingHTTPServer):
699
+ self._server.serve_forever()
700
+
701
+ async def wait(self) -> tuple[str, str | None]:
702
+ """Wait for the authorization callback and return `(code, state)`.
703
+
704
+ Returns:
705
+ The OAuth authorization code and optional state.
706
+
707
+ Raises:
708
+ _LoopbackCallbackTimeoutError: If no callback arrives before the timeout.
709
+ _LoopbackCallbackUnavailableError: If the server could not be started.
710
+ RuntimeError: If the provider returned an OAuth error or the callback
711
+ URL lacked a `code` parameter.
712
+ """ # noqa: DOC502 - _LoopbackCallbackUnavailableError/RuntimeError set on future
713
+ import asyncio
714
+
715
+ try:
716
+ return await asyncio.wait_for(
717
+ asyncio.wrap_future(self._future),
718
+ timeout=_LOOPBACK_CALLBACK_TIMEOUT,
719
+ )
720
+ except TimeoutError as exc:
721
+ msg = "Browser callback was not received before the timeout."
722
+ raise _LoopbackCallbackTimeoutError(msg) from exc
723
+
724
+ def fail(self, exc: Exception) -> None:
725
+ """Poison the future so `wait()` raises `exc` immediately.
726
+
727
+ Args:
728
+ exc: Exception to surface to the awaiting coroutine.
729
+ """
730
+ if not self._future.done():
731
+ self._future.set_exception(exc)
732
+
733
+ def close(self) -> None:
734
+ """Stop the local callback server and release its socket."""
735
+ if self._closed:
736
+ return
737
+ self._closed = True
738
+ from http.server import ThreadingHTTPServer
739
+
740
+ if isinstance(self._server, ThreadingHTTPServer):
741
+ if self._started:
742
+ self._server.shutdown()
743
+ self._server.server_close()
744
+
745
+ def _handle_get(self, request: object) -> None:
746
+ from http.server import BaseHTTPRequestHandler
747
+
748
+ handler = request
749
+ if not isinstance(handler, BaseHTTPRequestHandler):
750
+ return
751
+
752
+ if self._future.done():
753
+ # Duplicate browser request (retry, prefetch, favicon) after the
754
+ # flow already completed. Respond and return without touching the
755
+ # future — avoids InvalidStateError from a concurrent set_result.
756
+ # Branch on the future's terminal state: a previous error must
757
+ # not be papered over with a success page.
758
+ if self._future.exception() is None:
759
+ self._send_html(
760
+ handler,
761
+ 200,
762
+ _oauth_success_html(
763
+ "MCP authorization complete. "
764
+ "You can close this tab and return to your terminal.",
765
+ ),
766
+ )
767
+ else:
768
+ self._send_html(
769
+ handler,
770
+ 400,
771
+ _oauth_error_html(
772
+ "Authorization did not complete. "
773
+ "Return to your terminal for details.",
774
+ ),
775
+ )
776
+ return
777
+
778
+ parsed = urlparse(handler.path)
779
+ if parsed.path != _LOOPBACK_CALLBACK_PATH:
780
+ self._send_html(
781
+ handler,
782
+ 404,
783
+ _oauth_error_html("Callback route not found."),
784
+ )
785
+ return
786
+
787
+ params = parse_qs(parsed.query)
788
+ if "error" in params:
789
+ err_code = params["error"][0]
790
+ err_desc = (params.get("error_description") or [""])[0]
791
+ detail = f": {err_desc}" if err_desc else ""
792
+ msg = f"Authorization denied by provider: {err_code}{detail}"
793
+ self._future.set_exception(RuntimeError(msg))
794
+ self._send_html(handler, 400, _oauth_error_html(msg))
795
+ return
796
+
797
+ if "code" not in params or not params["code"]:
798
+ msg = "Callback URL is missing the 'code' parameter."
799
+ self._future.set_exception(RuntimeError(msg))
800
+ self._send_html(handler, 400, _oauth_error_html(msg))
801
+ return
802
+
803
+ self._future.set_result((params["code"][0], (params.get("state") or [None])[0]))
804
+ self._send_html(
805
+ handler,
806
+ 200,
807
+ _oauth_success_html(
808
+ "MCP authorization complete. "
809
+ "You can close this tab and return to your terminal.",
810
+ ),
811
+ )
812
+
813
+ @staticmethod
814
+ def _send_html(handler: object, status: int, body: str) -> None:
815
+ from http.server import BaseHTTPRequestHandler
816
+
817
+ if not isinstance(handler, BaseHTTPRequestHandler):
818
+ return
819
+ payload = body.encode("utf-8")
820
+ handler.send_response(status)
821
+ handler.send_header("Content-Type", "text/html; charset=utf-8")
822
+ handler.send_header("Content-Length", str(len(payload)))
823
+ handler.end_headers()
824
+ handler.wfile.write(payload)
825
+
826
+
827
+ def _oauth_success_html(message: str) -> str:
828
+ return _oauth_result_html(
829
+ title="Authorization complete",
830
+ heading="You're signed in",
831
+ message=message,
832
+ status="success",
833
+ )
834
+
835
+
836
+ def _oauth_error_html(message: str) -> str:
837
+ return _oauth_result_html(
838
+ title="Authorization failed",
839
+ heading="Authorization failed",
840
+ message=message,
841
+ status="error",
842
+ )
843
+
844
+
845
+ def _oauth_result_html(
846
+ *,
847
+ title: str,
848
+ heading: str,
849
+ message: str,
850
+ status: Literal["success", "error"],
851
+ ) -> str:
852
+ accent = "#137333" if status == "success" else "#b3261e"
853
+ background = "#eef7f0" if status == "success" else "#fceeee"
854
+ mark = "✓" if status == "success" else "!"
855
+ escaped_title = html.escape(title)
856
+ escaped_heading = html.escape(heading)
857
+ escaped = html.escape(message)
858
+ # `window.close()` is only honored for tabs the script itself opened
859
+ # (browser policy). The loopback flow launches the browser via
860
+ # `webbrowser.open`, so the callback tab was usually opened by the OS and
861
+ # the browser refuses to close it. Attempt the close for the rare
862
+ # script-opened case; the static message already reads correctly whether
863
+ # or not the tab closes, so nothing is rewritten and the text never shifts.
864
+ auto_close = (
865
+ "<script>setTimeout(function(){window.close();},1000);</script>"
866
+ if status == "success"
867
+ else ""
868
+ )
869
+ return (
870
+ '<!doctype html><html><head><meta charset="utf-8">'
871
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
872
+ f"<title>{escaped_title}</title>"
873
+ "<style>"
874
+ "body{margin:0;min-height:100vh;display:grid;place-items:center;"
875
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
876
+ "background:#f8faf9;color:#1f2328}"
877
+ ".panel{width:min(480px,calc(100vw - 40px));box-sizing:border-box;"
878
+ "padding:32px;border:1px solid #d8dee4;border-radius:8px;"
879
+ "background:#fff;box-shadow:0 18px 45px rgba(31,35,40,.08)}"
880
+ ".mark{width:44px;height:44px;border-radius:50%;display:grid;"
881
+ "place-items:center;margin-bottom:20px;font-weight:700}"
882
+ "h1{font-size:24px;line-height:1.2;margin:0 0 10px}"
883
+ "p{font-size:15px;line-height:1.5;margin:0;color:#57606a}"
884
+ "</style></head><body>"
885
+ '<main class="panel">'
886
+ f'<div class="mark" style="background:{background};color:{accent}">{mark}</div>'
887
+ f"<h1>{escaped_heading}</h1><p>{escaped}</p>"
888
+ "</main>"
889
+ f"{auto_close}"
890
+ "</body></html>"
891
+ )
892
+
893
+
894
+ class MCPReauthRequiredError(RuntimeError):
895
+ """Raised when an MCP server needs interactive re-authentication."""
896
+
897
+ def __init__(self, server_name: str) -> None:
898
+ """Build with `server_name` so the message tells the user what to fix."""
899
+ self.server_name = server_name
900
+ super().__init__(
901
+ f"MCP server {server_name!r} needs re-authentication. "
902
+ f"Run `/mcp login {server_name}` in the TUI, or "
903
+ f"`dcode mcp login {server_name}` from the shell.",
904
+ )
905
+
906
+
907
+ def _make_reauth_required_handlers(
908
+ server_name: str,
909
+ ) -> tuple[RedirectHandler, CallbackHandler]:
910
+ """Return OAuth handlers that refuse to prompt and raise instead.
911
+
912
+ Used in non-interactive server mode so that a missing or expired token
913
+ surfaces as `MCPReauthRequiredError` rather than hanging on `input()`.
914
+ """
915
+
916
+ async def redirect(_auth_url: str) -> None: # noqa: RUF029
917
+ raise MCPReauthRequiredError(server_name)
918
+
919
+ async def callback() -> tuple[str, str | None]: # noqa: RUF029
920
+ raise MCPReauthRequiredError(server_name)
921
+
922
+ return redirect, callback
923
+
924
+
925
+ def _make_paste_back_handlers(
926
+ *,
927
+ extra_auth_params: dict[str, str] | None = None,
928
+ ui: OAuthInteraction | None = None,
929
+ ) -> tuple[RedirectHandler, CallbackHandler]:
930
+ """Create paste-back redirect and callback handlers for OAuth.
931
+
932
+ Args:
933
+ extra_auth_params: Extra query params to append to the auth URL.
934
+ ui: Interaction surface for the auth URL display and the
935
+ pasted-back callback URL prompt.
936
+
937
+ Returns:
938
+ A tuple of `(redirect_handler, callback_handler)`.
939
+ """
940
+ extras = dict(extra_auth_params or {})
941
+ interaction = ui if ui is not None else _default_ui()
942
+
943
+ async def redirect(auth_url: str) -> None:
944
+ final_url = _append_query_params(auth_url, extras) if extras else auth_url
945
+ await interaction.show_authorize_url(final_url, opened_in_browser=False)
946
+
947
+ async def callback() -> tuple[str, str | None]:
948
+ url = await interaction.request_callback_url()
949
+ return _parse_callback_url(url)
950
+
951
+ return redirect, callback
952
+
953
+
954
+ def _parse_callback_url(url: str) -> tuple[str, str | None]:
955
+ """Parse a provider callback URL into `(code, state)`.
956
+
957
+ Args:
958
+ url: Raw callback URL pasted by the user.
959
+
960
+ Returns:
961
+ The `code` and optional `state` query parameters.
962
+
963
+ Raises:
964
+ RuntimeError: If the URL contains `error=` or lacks `code`.
965
+ """
966
+ params = parse_qs(urlparse(url).query)
967
+ if "error" in params:
968
+ err_code = params["error"][0]
969
+ err_desc = (params.get("error_description") or [""])[0]
970
+ detail = f": {err_desc}" if err_desc else ""
971
+ msg = f"Authorization denied by provider: {err_code}{detail}"
972
+ raise RuntimeError(msg)
973
+ if "code" not in params or not params["code"]:
974
+ msg = "Callback URL is missing the 'code' parameter."
975
+ raise RuntimeError(msg)
976
+ return params["code"][0], (params.get("state") or [None])[0]
977
+
978
+
979
+ def _default_ui() -> OAuthInteraction:
980
+ """Return the default `OAuthInteraction` implementation (CLI stdio)."""
981
+ from deepagents_code.mcp_oauth_ui import CliOAuthInteraction
982
+
983
+ return CliOAuthInteraction()
984
+
985
+
986
+ def _make_loopback_handlers(
987
+ *,
988
+ callback_server: _LoopbackOAuthCallbackServer,
989
+ extra_auth_params: dict[str, str] | None = None,
990
+ ui: OAuthInteraction | None = None,
991
+ ) -> tuple[RedirectHandler, CallbackHandler]:
992
+ """Create browser loopback redirect and callback handlers for OAuth.
993
+
994
+ Args:
995
+ callback_server: Prepared local callback server for this login attempt.
996
+ The socket is bound when the returned redirect handler is first called.
997
+ extra_auth_params: Extra query params to append to the auth URL.
998
+ ui: Interaction surface for the browser-opened or fallback prompts.
999
+
1000
+ Returns:
1001
+ A tuple of `(redirect_handler, callback_handler)`.
1002
+ """
1003
+ extras = dict(extra_auth_params or {})
1004
+ interaction = ui if ui is not None else _default_ui()
1005
+ last_authorize_url: str | None = None
1006
+ _paste_redirect, paste_callback = _make_paste_back_handlers(
1007
+ extra_auth_params=extra_auth_params,
1008
+ ui=interaction,
1009
+ )
1010
+
1011
+ async def redirect(auth_url: str) -> None:
1012
+ import asyncio
1013
+ import webbrowser
1014
+
1015
+ nonlocal last_authorize_url
1016
+ final_url = _append_query_params(auth_url, extras) if extras else auth_url
1017
+ last_authorize_url = final_url
1018
+
1019
+ # Resolve a browser explicitly before opening so headless / SSH
1020
+ # environments fall through to paste-back without burning the
1021
+ # 300s loopback timeout. `webbrowser.open` can return `True` in
1022
+ # those environments even when nothing launches.
1023
+ try:
1024
+ await asyncio.to_thread(webbrowser.get)
1025
+ has_browser = True
1026
+ except webbrowser.Error:
1027
+ has_browser = False
1028
+
1029
+ if has_browser:
1030
+ opened = await asyncio.to_thread(webbrowser.open, final_url)
1031
+ else:
1032
+ opened = False
1033
+ if not opened:
1034
+ callback_server.fail(
1035
+ _LoopbackCallbackUnavailableError(
1036
+ "No browser is available to complete the OAuth flow.",
1037
+ ),
1038
+ )
1039
+ await interaction.show_authorize_url(final_url, opened_in_browser=False)
1040
+ return
1041
+ try:
1042
+ callback_server.start()
1043
+ except OSError as exc:
1044
+ logger.warning(
1045
+ "Could not start loopback OAuth callback server on port %s: %s",
1046
+ callback_server.port,
1047
+ exc,
1048
+ )
1049
+ msg = "Local OAuth callback server could not be started."
1050
+ callback_server.fail(_LoopbackCallbackUnavailableError(msg))
1051
+ await interaction.show_notice(
1052
+ "Could not start the local OAuth callback server.",
1053
+ )
1054
+ await interaction.show_authorize_url(final_url, opened_in_browser=False)
1055
+ return
1056
+ await interaction.show_authorize_url(final_url, opened_in_browser=True)
1057
+
1058
+ async def callback() -> tuple[str, str | None]:
1059
+ try:
1060
+ return await callback_server.wait()
1061
+ except (
1062
+ _LoopbackCallbackTimeoutError,
1063
+ _LoopbackCallbackUnavailableError,
1064
+ ) as exc:
1065
+ if last_authorize_url is not None:
1066
+ await interaction.show_authorize_url(
1067
+ last_authorize_url,
1068
+ opened_in_browser=False,
1069
+ )
1070
+ await interaction.show_notice(
1071
+ f"{exc}\nPaste the full callback URL instead.",
1072
+ )
1073
+ return await paste_callback()
1074
+ finally:
1075
+ callback_server.close()
1076
+
1077
+ return redirect, callback
1078
+
1079
+
1080
+ def _append_query_params(url: str, params: dict[str, str]) -> str:
1081
+ """Return `url` with `params` replacing any same-named query keys."""
1082
+ from urllib.parse import urlencode, urlunparse
1083
+
1084
+ parsed = urlparse(url)
1085
+ existing = dict(parse_qs(parsed.query, keep_blank_values=True))
1086
+ for key, value in params.items():
1087
+ existing[key] = [value]
1088
+ return urlunparse(parsed._replace(query=urlencode(existing, doseq=True)))
1089
+
1090
+
1091
+ def _strip_duplicate_client_id_under_basic_auth(context: OAuthContext) -> None:
1092
+ """Drop the redundant body `client_id` when token auth uses HTTP Basic.
1093
+
1094
+ The MCP SDK copies `client_id` into the token-request body (on both the
1095
+ authorization-code exchange and refresh paths) and, for
1096
+ `token_endpoint_auth_method == "client_secret_basic"`, *also* sends it in the
1097
+ `Authorization: Basic` header. RFC 6749 §2.3.1 carries the client identity in
1098
+ the header for Basic auth, so the body copy is redundant; some authorization
1099
+ servers (e.g. Pylon) reject the duplicate identity with an `OAuthTokenError`.
1100
+ Wrapping `prepare_token_auth` strips the body `client_id` only when a Basic
1101
+ header is present, leaving `client_secret_post`/`none` flows untouched.
1102
+ """
1103
+ original = context.prepare_token_auth
1104
+
1105
+ def prepare_token_auth(
1106
+ data: dict[str, str],
1107
+ headers: dict[str, str] | None = None,
1108
+ ) -> tuple[dict[str, str], dict[str, str]]:
1109
+ data, headers = original(data, headers)
1110
+ # RFC 7617 makes the auth-scheme token case-insensitive, so match
1111
+ # `basic` regardless of casing rather than coupling to the SDK's exact
1112
+ # `Basic ` literal.
1113
+ if headers.get("Authorization", "").lower().startswith("basic "):
1114
+ data = {k: v for k, v in data.items() if k != "client_id"}
1115
+ return data, headers
1116
+
1117
+ context.prepare_token_auth = prepare_token_auth # ty: ignore[invalid-assignment]
1118
+
1119
+
1120
+ class _ExpiryAwareOAuthClientProvider(OAuthClientProvider):
1121
+ """`OAuthClientProvider` that restores `token_expiry_time` from storage.
1122
+
1123
+ Upstream `_initialize` loads stored tokens but leaves
1124
+ `context.token_expiry_time` at `None`, which makes `is_token_valid`
1125
+ report any stored access token — even one that expired hours ago —
1126
+ as valid. The SDK then sends a stale `Bearer`, gets a 401, and falls
1127
+ into a full re-auth (browser) instead of the `refresh_token` grant.
1128
+
1129
+ Restoring the persisted absolute expiry to the context after load
1130
+ lets the SDK's refresh-when-invalid-and-refreshable branch fire on
1131
+ the first request after a cold start. When the sidecar is absent
1132
+ (older token files written before this field existed), assume the
1133
+ token is expired so the refresh path still gets a chance before
1134
+ falling back to 401.
1135
+ """
1136
+
1137
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
1138
+ self._suppress_expected_reauth_logs = bool(
1139
+ kwargs.pop("suppress_expected_reauth_logs", False)
1140
+ )
1141
+ super().__init__(*args, **kwargs)
1142
+ _strip_duplicate_client_id_under_basic_auth(self.context)
1143
+
1144
+ async def _initialize(self) -> None:
1145
+ # Overrides a leading-underscore SDK method; behavior depends on
1146
+ # `super()._initialize()` populating `context.current_tokens` from
1147
+ # storage. If an upstream rename or refactor breaks that contract,
1148
+ # the test suite's TestExpiryAwareOAuthClientProvider cases will
1149
+ # fail loudly rather than silently regress to the 401-on-restart
1150
+ # bug this class exists to prevent.
1151
+ await super()._initialize()
1152
+ await self._apply_stored_expiry()
1153
+
1154
+ async def _apply_stored_expiry(self) -> None:
1155
+ """Seed `context.token_expiry_time` from the persisted sidecar.
1156
+
1157
+ Upstream `_initialize` loads stored tokens but leaves the expiry unset,
1158
+ so a token whose access portion expired long ago still reports as valid
1159
+ and is sent stale. Restoring the absolute expiry recorded beside the
1160
+ token lets `is_token_valid` return `False` in time for the cheaper
1161
+ refresh grant to fire. Also caches persisted OAuth metadata so the
1162
+ refresh uses the advertised token endpoint. Safe to call repeatedly, so
1163
+ it doubles as the post-reload expiry refresh.
1164
+ """
1165
+ if self.context.oauth_metadata is None:
1166
+ get_oauth_metadata = getattr(
1167
+ self.context.storage,
1168
+ "get_oauth_metadata",
1169
+ None,
1170
+ )
1171
+ if get_oauth_metadata is not None:
1172
+ self.context.oauth_metadata = await get_oauth_metadata()
1173
+ get_expires_at = getattr(self.context.storage, "get_expires_at", None)
1174
+ if get_expires_at is None:
1175
+ return
1176
+ expires_at = await get_expires_at()
1177
+ tokens = self.context.current_tokens
1178
+ if expires_at is None:
1179
+ # Use 1.0 (one second after the Unix epoch) rather than 0.0 so the
1180
+ # SDK's `not self.token_expiry_time` falsy-zero check doesn't treat
1181
+ # the sentinel as "no expiry known" and mark the token valid again.
1182
+ if tokens is not None and tokens.refresh_token:
1183
+ self.context.token_expiry_time = 1.0
1184
+ elif tokens is not None and tokens.access_token:
1185
+ # Legacy file with no refresh_token: nothing we can do to
1186
+ # pre-empt expiry. Surface a structural breadcrumb so the
1187
+ # 401-then-browser-reauth flow isn't completely silent.
1188
+ logger.info(
1189
+ "Legacy MCP token file for %s has no refresh_token; "
1190
+ "cannot pre-empt expiry. The next 401 will trigger "
1191
+ "browser re-auth.",
1192
+ self.context.server_url,
1193
+ )
1194
+ return
1195
+ if expires_at - time.time() < _REFRESH_SAFETY_MARGIN_SECONDS:
1196
+ # Token already inside its safety margin (or past it) — likely a
1197
+ # cold start after a long pause, or a misconfigured server issuing
1198
+ # sub-margin lifetimes. Log only the duration, never any token
1199
+ # material.
1200
+ logger.debug(
1201
+ "MCP token for %s is within %.0fs of expiry on load; "
1202
+ "scheduling refresh on next request.",
1203
+ self.context.server_url,
1204
+ _REFRESH_SAFETY_MARGIN_SECONDS,
1205
+ )
1206
+ self.context.token_expiry_time = expires_at - _REFRESH_SAFETY_MARGIN_SECONDS
1207
+
1208
+ async def _reload_tokens_from_storage(self) -> None:
1209
+ """Re-read persisted tokens so a peer's refresh is observed.
1210
+
1211
+ Another dcode process (or a separate provider instance in this process)
1212
+ may have rotated the refresh token on disk while this provider held a
1213
+ now-stale copy in memory. Re-reading before deciding to refresh keeps
1214
+ this provider from replaying an already-rotated refresh token, which
1215
+ the LangSmith OAuth server treats as reuse and punishes by revoking the
1216
+ whole identity+client token family.
1217
+ """
1218
+ self.context.current_tokens = await self.context.storage.get_tokens()
1219
+ client_info = await self.context.storage.get_client_info()
1220
+ if client_info is not None:
1221
+ self.context.client_info = client_info
1222
+ await self._apply_stored_expiry()
1223
+
1224
+ async def _acquire_refresh_lock(self, lock: FileLock) -> bool:
1225
+ """Wait for the cross-process refresh lock off the event loop.
1226
+
1227
+ `lock.acquire` blocks for up to `_REFRESH_LOCK_TIMEOUT_SECONDS` while a
1228
+ peer finishes its refresh, so it runs in a worker thread to avoid
1229
+ stalling the event loop for that long.
1230
+
1231
+ Args:
1232
+ lock: The `filelock.FileLock` serializing refreshes for this server
1233
+ (backed by the sidecar `.lock` file, not the token file).
1234
+
1235
+ Returns:
1236
+ `True` when the lock was acquired; `False` when the wait timed out
1237
+ or the lock could not be created, signalling the caller to avoid
1238
+ using the possibly in-flight refresh token after reloading.
1239
+ """
1240
+ try:
1241
+ await asyncio.to_thread(
1242
+ lock.acquire,
1243
+ timeout=_REFRESH_LOCK_TIMEOUT_SECONDS,
1244
+ )
1245
+ except Timeout:
1246
+ # A timeout means a peer may still be mid-refresh with this same
1247
+ # token. Do not refresh unlocked: rotating-token servers can treat
1248
+ # the second grant as reuse and revoke the whole token family.
1249
+ logger.warning(
1250
+ "Timed out after %.0fs waiting for the MCP token refresh lock "
1251
+ "for %s; skipping refresh to avoid refresh-token reuse.",
1252
+ _REFRESH_LOCK_TIMEOUT_SECONDS,
1253
+ self.context.server_url,
1254
+ )
1255
+ return False
1256
+ except OSError as exc:
1257
+ # Creating/locking the sidecar can fail (read-only or missing
1258
+ # tokens dir, permission denial on a hardened host). Avoid an
1259
+ # unlocked refresh so we do not replay a rotating refresh token if a
1260
+ # peer did manage to take the lock.
1261
+ logger.warning(
1262
+ "Could not acquire the MCP token refresh lock for %s (%s); "
1263
+ "skipping refresh to avoid refresh-token reuse.",
1264
+ self.context.server_url,
1265
+ type(exc).__name__,
1266
+ )
1267
+ return False
1268
+ return True
1269
+
1270
+ @contextlib.asynccontextmanager
1271
+ async def _refresh_lock_guard(self, lock_path: Path) -> AsyncIterator[bool]:
1272
+ """Hold the cross-process refresh lock across the serialized refresh.
1273
+
1274
+ Acquires the lock (waiting up to `_REFRESH_LOCK_TIMEOUT_SECONDS`; on
1275
+ timeout it yields `False` so the caller can avoid the refresh grant) and
1276
+ always releases it on exit. Release is gated on `lock.is_locked` rather
1277
+ than the acquire result, so a cancellation that lands *after* the worker
1278
+ thread took the lock still frees it instead of orphaning it until GC.
1279
+
1280
+ Args:
1281
+ lock_path: Sibling `.lock` path from `FileTokenStorage`.
1282
+
1283
+ Yields:
1284
+ Whether the refresh lock was acquired.
1285
+ """
1286
+ # `thread_local=False` because acquire and release run in different
1287
+ # `asyncio.to_thread` worker threads; the default would refuse the
1288
+ # cross-thread release and leak the OS lock until process exit.
1289
+ lock = FileLock(str(lock_path), thread_local=False)
1290
+ try:
1291
+ yield await self._acquire_refresh_lock(lock)
1292
+ finally:
1293
+ if lock.is_locked:
1294
+ await asyncio.to_thread(lock.release)
1295
+
1296
+ async def _persist_oauth_metadata(self) -> None:
1297
+ """Persist discovered public OAuth metadata when storage supports it."""
1298
+ if self.context.oauth_metadata is None:
1299
+ return
1300
+ set_oauth_metadata = getattr(self.context.storage, "set_oauth_metadata", None)
1301
+ if set_oauth_metadata is not None:
1302
+ await set_oauth_metadata(self.context.oauth_metadata)
1303
+
1304
+ async def _handle_token_response(self, response: httpx.Response) -> None:
1305
+ """Persist tokens and any metadata discovered during full OAuth login."""
1306
+ await super()._handle_token_response(response)
1307
+ await self._persist_oauth_metadata()
1308
+
1309
+ async def _handle_locked_refresh_response(self, response: httpx.Response) -> bool:
1310
+ """Handle a serialized refresh without bypassing SDK re-auth fallback.
1311
+
1312
+ Args:
1313
+ response: Refresh endpoint response returned through the auth generator.
1314
+
1315
+ Returns:
1316
+ `True` when refresh succeeded, otherwise `False` so the caller can
1317
+ continue into the delegated SDK flow.
1318
+ """
1319
+ try:
1320
+ return bool(await self._handle_refresh_response(response))
1321
+ except Exception:
1322
+ if response.status_code not in _EXPECTED_REAUTH_REFRESH_STATUS_CODES:
1323
+ raise
1324
+ logger.debug(
1325
+ "Locked MCP token refresh for %s failed with %s; "
1326
+ "deferring to the SDK re-auth flow.",
1327
+ self.context.server_url,
1328
+ response.status_code,
1329
+ )
1330
+ self.context.clear_tokens()
1331
+ self._initialized = False
1332
+ return False
1333
+
1334
+ async def async_auth_flow(
1335
+ self,
1336
+ request: httpx.Request,
1337
+ ) -> AsyncGenerator[httpx.Request, httpx.Response]:
1338
+ """Discover and cache OAuth metadata before the SDK refresh branch.
1339
+
1340
+ Yields:
1341
+ HTTP requests for OAuth metadata discovery and the delegated SDK auth flow.
1342
+ """
1343
+ async with self.context.lock:
1344
+ if not self._initialized:
1345
+ await self._initialize()
1346
+ self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
1347
+ if (
1348
+ not self.context.is_token_valid()
1349
+ and self.context.can_refresh_token()
1350
+ and self.context.oauth_metadata is None
1351
+ ):
1352
+ # Pre-empt the SDK's 401-path discovery so its refresh branch
1353
+ # finds populated `oauth_metadata` and uses the advertised token
1354
+ # endpoint instead of guessing `/token`. The resource-metadata
1355
+ # URL is `None`: no 401 yet, so no `WWW-Authenticate` to read.
1356
+ try:
1357
+ prm_urls = build_protected_resource_metadata_discovery_urls(
1358
+ None,
1359
+ self.context.server_url,
1360
+ )
1361
+ for url in prm_urls:
1362
+ # ASYNC119: yielding the request to receive its response is
1363
+ # this auth generator's handshake protocol, not a value
1364
+ # escaping a context manager.
1365
+ response = yield create_oauth_metadata_request(url) # noqa: ASYNC119
1366
+ prm = await handle_protected_resource_response(response)
1367
+ if prm is None:
1368
+ logger.debug(
1369
+ "Protected resource metadata discovery failed: %s",
1370
+ url,
1371
+ )
1372
+ continue
1373
+ self.context.protected_resource_metadata = prm
1374
+ self.context.auth_server_url = str(prm.authorization_servers[0])
1375
+ break
1376
+
1377
+ asm_urls = build_oauth_authorization_server_metadata_discovery_urls(
1378
+ self.context.auth_server_url,
1379
+ self.context.server_url,
1380
+ )
1381
+ for url in asm_urls:
1382
+ # ASYNC119: yielding the request to receive its response is
1383
+ # this auth generator's handshake protocol, not a value
1384
+ # escaping a context manager.
1385
+ response = yield create_oauth_metadata_request(url) # noqa: ASYNC119
1386
+ ok, metadata = await handle_auth_metadata_response(response)
1387
+ if not ok:
1388
+ break
1389
+ if metadata is None:
1390
+ logger.debug("OAuth metadata discovery failed: %s", url)
1391
+ continue
1392
+ self.context.oauth_metadata = metadata
1393
+ await self._persist_oauth_metadata()
1394
+ break
1395
+ except httpx.HTTPError as exc:
1396
+ # Log only the exception type, never its payload — discovery
1397
+ # responses travel the same channel as bearer tokens.
1398
+ logger.debug(
1399
+ "Pre-emptive OAuth metadata discovery for %s raised %s; "
1400
+ "deferring to the SDK auth flow.",
1401
+ self.context.server_url,
1402
+ type(exc).__name__,
1403
+ )
1404
+
1405
+ if (
1406
+ not self.context.is_token_valid()
1407
+ and self.context.can_refresh_token()
1408
+ and isinstance(self.context.storage, FileTokenStorage)
1409
+ ):
1410
+ # Serialize the refresh across processes and provider instances.
1411
+ # Without this, two holders of the same token file can both
1412
+ # replay the same refresh token; the LangSmith OAuth server
1413
+ # rotates refresh tokens and revokes the entire token family on
1414
+ # reuse, which surfaces as requests hanging until a full
1415
+ # re-auth. `self.context.lock` only guards this one provider,
1416
+ # so a file lock is required for the cross-process case.
1417
+ async with self._refresh_lock_guard(
1418
+ self.context.storage.refresh_lock_path
1419
+ ) as refresh_lock_acquired:
1420
+ # A peer may have rotated the token while we waited for the
1421
+ # lock; reload so a now-valid token skips the refresh.
1422
+ await self._reload_tokens_from_storage()
1423
+ if (
1424
+ not self.context.is_token_valid()
1425
+ and self.context.can_refresh_token()
1426
+ ):
1427
+ if refresh_lock_acquired:
1428
+ # ASYNC119: the refresh lock must stay held across this
1429
+ # yield — the request/response round-trip is the
1430
+ # critical section being serialized. Release is safe
1431
+ # because httpx deterministically drives and
1432
+ # `aclose()`s this generator (see the delegation note
1433
+ # below), so the guard's `finally` runs rather than
1434
+ # deferring cleanup to GC.
1435
+ refresh_response = yield await self._refresh_token() # noqa: ASYNC119
1436
+ await self._handle_locked_refresh_response(refresh_response)
1437
+ else:
1438
+ # The delegated SDK flow has its own refresh branch;
1439
+ # clear only in-memory tokens so this request falls
1440
+ # through to re-auth instead of replaying the refresh
1441
+ # token while another process may still be using it.
1442
+ self.context.clear_tokens()
1443
+
1444
+ # Delegate to the SDK flow by manually pumping the inner generator so
1445
+ # the HTTP responses httpx feeds back via `auth_flow.asend(response)`
1446
+ # are forwarded into it. A plain `async for` would advance the inner
1447
+ # generator with `__anext__()` (i.e. `asend(None)`), discarding every
1448
+ # response — the SDK's `response = yield request` and refresh-path
1449
+ # `yield refresh_request` would then see `None` and raise
1450
+ # `AttributeError: 'NoneType' object has no attribute 'status_code'`,
1451
+ # surfacing as the `ExceptionGroup` users hit on MCP OAuth login.
1452
+ # httpx primes the flow with `__anext__()`, then drives it with
1453
+ # `asend`/`aclose` (never `athrow`), so forwarding sent values and
1454
+ # closing the inner generator on `GeneratorExit` is sufficient — no
1455
+ # `athrow` forwarding needed.
1456
+ token: contextvars.Token[bool] | None = None
1457
+ if self._suppress_expected_reauth_logs:
1458
+ token = _SUPPRESS_EXPECTED_REAUTH_LOGS.set(True)
1459
+ inner = super().async_auth_flow(request)
1460
+ try:
1461
+ # Prime with `anext()` (no response to send yet); thereafter every
1462
+ # resume carries httpx's response back in via `asend`.
1463
+ flow_request = await anext(inner)
1464
+ while True:
1465
+ response = yield flow_request
1466
+ flow_request = await inner.asend(response)
1467
+ except StopAsyncIteration:
1468
+ return
1469
+ finally:
1470
+ await inner.aclose()
1471
+ if token is not None:
1472
+ _SUPPRESS_EXPECTED_REAUTH_LOGS.reset(token)
1473
+
1474
+
1475
+ def build_oauth_provider(
1476
+ *,
1477
+ server_name: str,
1478
+ server_url: str,
1479
+ storage: TokenStorage,
1480
+ extra_auth_params: dict[str, str] | None = None,
1481
+ interactive: bool = True,
1482
+ ui: OAuthInteraction | None = None,
1483
+ ) -> OAuthClientProvider:
1484
+ """Construct an `OAuthClientProvider` for an MCP server.
1485
+
1486
+ Args:
1487
+ server_name: MCP server name used in re-auth messages.
1488
+ server_url: Remote MCP server URL.
1489
+ storage: Token storage implementation for this server.
1490
+ extra_auth_params: Optional query params for the interactive auth URL.
1491
+ interactive: Whether the provider may prompt on stdin.
1492
+ ui: Interaction surface used for URL display and paste-back
1493
+ input in interactive mode.
1494
+
1495
+ Returns:
1496
+ A configured `OAuthClientProvider`.
1497
+ """
1498
+ from deepagents_code.mcp_providers import resolve_provider
1499
+
1500
+ policy = resolve_provider(server_url)
1501
+ redirect_uri: str | None = None
1502
+
1503
+ if interactive:
1504
+ if policy.supports_loopback_callback():
1505
+ fixed = policy.loopback_port()
1506
+ if fixed is not None:
1507
+ port = fixed
1508
+ else:
1509
+ # Reuse the port from a prior DCR registration when available,
1510
+ # so the authorize request's redirect_uri matches what was
1511
+ # registered against the persisted client_id. A fresh random
1512
+ # port on every launch would otherwise invalidate the URI on
1513
+ # the second run and force the server to reject the request.
1514
+ stored = (
1515
+ storage.stored_loopback_port()
1516
+ if isinstance(storage, FileTokenStorage)
1517
+ else None
1518
+ )
1519
+ # No reusable port means any persisted registration can't be
1520
+ # paired with the random loopback port we're about to bind. Drop
1521
+ # a stale registration so the handshake re-runs DCR with a
1522
+ # matching redirect URI instead of failing with "invalid or
1523
+ # missing redirect_uri".
1524
+ if (
1525
+ stored is None
1526
+ and isinstance(storage, FileTokenStorage)
1527
+ and storage.discard_client_info_if_loopback_unusable()
1528
+ ):
1529
+ logger.info(
1530
+ "Discarded a stale MCP client registration for %s "
1531
+ "whose redirect URI can't serve loopback login; the "
1532
+ "handshake will register a fresh client.",
1533
+ server_name,
1534
+ )
1535
+ port = stored if stored is not None else _choose_loopback_port()
1536
+ callback_server = _LoopbackOAuthCallbackServer(port=port)
1537
+ redirect_uri = callback_server.redirect_uri
1538
+ redirect, callback = _make_loopback_handlers(
1539
+ callback_server=callback_server,
1540
+ extra_auth_params=extra_auth_params,
1541
+ ui=ui,
1542
+ )
1543
+ else:
1544
+ redirect, callback = _make_paste_back_handlers(
1545
+ extra_auth_params=extra_auth_params,
1546
+ ui=ui,
1547
+ )
1548
+ else:
1549
+ redirect, callback = _make_reauth_required_handlers(server_name=server_name)
1550
+
1551
+ metadata = (
1552
+ policy.client_metadata(redirect_uri=redirect_uri)
1553
+ if redirect_uri is not None
1554
+ else policy.client_metadata()
1555
+ )
1556
+
1557
+ return _ExpiryAwareOAuthClientProvider(
1558
+ server_url=server_url,
1559
+ client_metadata=metadata,
1560
+ storage=storage,
1561
+ redirect_handler=redirect,
1562
+ callback_handler=callback,
1563
+ suppress_expected_reauth_logs=not interactive,
1564
+ )
1565
+
1566
+
1567
+ async def _run_device_flow(
1568
+ *,
1569
+ device_code_url: str,
1570
+ token_url: str,
1571
+ client_id: str,
1572
+ scope: str | None = None,
1573
+ ui: OAuthInteraction | None = None,
1574
+ ) -> OAuthToken:
1575
+ """Run OAuth 2.0 Device Authorization Grant and return the token.
1576
+
1577
+ Args:
1578
+ device_code_url: Provider endpoint that issues a device + user code.
1579
+ token_url: Provider endpoint to poll for the access token.
1580
+ client_id: Registered OAuth client ID.
1581
+ scope: Optional space-delimited scope string.
1582
+ ui: Interaction surface used to display the device code.
1583
+
1584
+ Returns:
1585
+ The issued OAuth access token payload.
1586
+
1587
+ Raises:
1588
+ RuntimeError: If the device flow fails, times out, or the provider
1589
+ returns an unexpected HTTP status on the device-code request.
1590
+ """
1591
+ import asyncio
1592
+
1593
+ import httpx
1594
+
1595
+ interaction = ui if ui is not None else _default_ui()
1596
+
1597
+ init_data = {"client_id": client_id}
1598
+ if scope is not None:
1599
+ init_data["scope"] = scope
1600
+
1601
+ async with httpx.AsyncClient(timeout=30.0) as client:
1602
+ response = await client.post(
1603
+ device_code_url,
1604
+ data=init_data,
1605
+ headers={"Accept": "application/json"},
1606
+ )
1607
+ try:
1608
+ response.raise_for_status()
1609
+ except httpx.HTTPStatusError as exc:
1610
+ msg = (
1611
+ f"Device code request failed: HTTP {response.status_code} "
1612
+ f"from {device_code_url}."
1613
+ )
1614
+ raise RuntimeError(msg) from exc
1615
+ try:
1616
+ device = _DeviceCodeResponse.model_validate(response.json())
1617
+ except (ValueError, ValidationError) as exc:
1618
+ msg = (
1619
+ f"Device code response from {device_code_url} is missing "
1620
+ f"required fields: {exc}"
1621
+ )
1622
+ raise RuntimeError(msg) from exc
1623
+
1624
+ await interaction.show_device_code(
1625
+ verification_uri=device.verification_uri,
1626
+ user_code=device.user_code,
1627
+ expires_in=device.expires_in,
1628
+ )
1629
+
1630
+ interval = max(device.interval, 1)
1631
+ loop = asyncio.get_running_loop()
1632
+ deadline = loop.time() + device.expires_in
1633
+ while loop.time() < deadline:
1634
+ await asyncio.sleep(interval)
1635
+ token_response = await client.post(
1636
+ token_url,
1637
+ data={
1638
+ "client_id": client_id,
1639
+ "device_code": device.device_code,
1640
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
1641
+ },
1642
+ headers={"Accept": "application/json"},
1643
+ )
1644
+ # RFC 8628 §3.5 lets providers return `authorization_pending` /
1645
+ # `slow_down` with either a 200 or 400 response. Check the body
1646
+ # before raise_for_status so 400-returning providers work.
1647
+ try:
1648
+ body = token_response.json()
1649
+ except ValueError as exc:
1650
+ # Malformed JSON would otherwise cascade into a confusing
1651
+ # OAuthToken.model_validate({}) error below; log the cause
1652
+ # explicitly so debugging is possible.
1653
+ logger.warning(
1654
+ "Token endpoint %s returned non-JSON body: %s",
1655
+ token_url,
1656
+ exc,
1657
+ )
1658
+ body = {}
1659
+ err = body.get("error")
1660
+ if err == "authorization_pending":
1661
+ continue
1662
+ if err == "slow_down":
1663
+ interval += 5
1664
+ continue
1665
+ if err:
1666
+ msg = f"Device flow failed: {err}: {body.get('error_description', '')}"
1667
+ raise RuntimeError(msg)
1668
+ try:
1669
+ token_response.raise_for_status()
1670
+ except httpx.HTTPStatusError as exc:
1671
+ msg = (
1672
+ f"Token request failed: HTTP {token_response.status_code} "
1673
+ f"from {token_url}."
1674
+ )
1675
+ raise RuntimeError(msg) from exc
1676
+ try:
1677
+ return OAuthToken.model_validate(body)
1678
+ except ValidationError as exc:
1679
+ msg = (
1680
+ f"Token response from {token_url} is not a valid "
1681
+ f"OAuth token payload: {exc}"
1682
+ )
1683
+ raise RuntimeError(msg) from exc
1684
+
1685
+ msg = "Device flow timed out. Try logging in again."
1686
+ raise RuntimeError(msg)
1687
+
1688
+
1689
+ def format_login_failure(exc: BaseException) -> str:
1690
+ """Return a token-safe single-line summary of an OAuth-login exception.
1691
+
1692
+ OAuth handshakes commonly surface as `ExceptionGroup` (anyio task
1693
+ groups) or as MCP-SDK errors whose `args`/`repr` may include an
1694
+ `OAuthToken`. Never call `str()`/`repr()` on the raw exception for
1695
+ display or logging — instead, prefer a known-safe nested
1696
+ `MCPReauthRequiredError` message, fall back to the messages of our
1697
+ own loopback-related exception types, and degrade to a class-name
1698
+ chain for anything else.
1699
+
1700
+ Args:
1701
+ exc: Root exception caught from the login worker.
1702
+
1703
+ Returns:
1704
+ A user-displayable string that is safe to log and to render.
1705
+ """
1706
+ reauth = find_reauth_required(exc)
1707
+ if reauth is not None:
1708
+ return str(reauth)
1709
+
1710
+ from deepagents_code.mcp_tools import MCPConfigError
1711
+
1712
+ if isinstance(exc, MCPConfigError):
1713
+ # Config-interpolation errors are our own and are raised before the
1714
+ # OAuth handshake, so they carry no token material and their
1715
+ # field-scoped messages are safe (and useful) to render verbatim.
1716
+ return str(exc)
1717
+
1718
+ safe_types = (
1719
+ _LoopbackCallbackTimeoutError,
1720
+ _LoopbackCallbackUnavailableError,
1721
+ )
1722
+ if isinstance(exc, safe_types):
1723
+ return f"{type(exc).__name__}: {exc}"
1724
+
1725
+ parts: list[str] = []
1726
+ current: BaseException | None = exc
1727
+ visited: set[int] = set()
1728
+ while current is not None and id(current) not in visited:
1729
+ visited.add(id(current))
1730
+ parts.append(type(current).__name__)
1731
+ if isinstance(current, BaseExceptionGroup):
1732
+ parts.append(
1733
+ "[" + ", ".join(type(e).__name__ for e in current.exceptions[:5]) + "]"
1734
+ )
1735
+ break
1736
+ current = current.__cause__ or current.__context__
1737
+ return " -> ".join(parts) if parts else type(exc).__name__
1738
+
1739
+
1740
+ def find_reauth_required(exc: BaseException) -> MCPReauthRequiredError | None:
1741
+ """Find an `MCPReauthRequiredError` anywhere inside `exc`'s tree.
1742
+
1743
+ Walks `exceptions` (for `ExceptionGroup`), then `__cause__` and
1744
+ `__context__`, tracking visited nodes to terminate on cyclic chains.
1745
+
1746
+ Args:
1747
+ exc: Root exception to inspect.
1748
+
1749
+ Returns:
1750
+ The nested `MCPReauthRequiredError`, or `None` if not present.
1751
+ """
1752
+ visited: set[int] = set()
1753
+ stack: list[BaseException] = [exc]
1754
+ while stack:
1755
+ current = stack.pop()
1756
+ if id(current) in visited:
1757
+ continue
1758
+ visited.add(id(current))
1759
+ if isinstance(current, MCPReauthRequiredError):
1760
+ return current
1761
+ if isinstance(current, BaseExceptionGroup):
1762
+ stack.extend(current.exceptions)
1763
+ cause = current.__cause__ or current.__context__
1764
+ if cause is not None:
1765
+ stack.append(cause)
1766
+ return None
1767
+
1768
+
1769
+ _BEARER_SCHEME_RE = re.compile(r"(?:^|,)\s*bearer\b", re.IGNORECASE)
1770
+ """Match a `Bearer` auth scheme at the start of a challenge or after a comma.
1771
+
1772
+ A `WWW-Authenticate` line may list several schemes (RFC 7235); anchoring to
1773
+ the start or a preceding comma finds `Bearer` even when it isn't listed first.
1774
+ """
1775
+
1776
+ _RESOURCE_METADATA_RE = re.compile(
1777
+ r'(?:^|[\s,])resource_metadata\s*=\s*"?([^",\s]+)',
1778
+ re.IGNORECASE,
1779
+ )
1780
+ """Capture the RFC 9728 `resource_metadata` URL from a Bearer challenge."""
1781
+
1782
+
1783
+ def _oauth_resource_challenge(headers: httpx.Headers) -> str | None:
1784
+ """Return the RFC 9728 `resource_metadata` URL from a Bearer challenge.
1785
+
1786
+ A single `WWW-Authenticate` header line may carry several comma-separated
1787
+ challenges (RFC 7235), and a response may repeat the header. Scan every
1788
+ value for a `Bearer` scheme — anywhere in the line, not only first — that
1789
+ advertises a `resource_metadata` parameter.
1790
+
1791
+ Args:
1792
+ headers: Response headers to inspect.
1793
+
1794
+ Returns:
1795
+ The `resource_metadata` URL when a Bearer challenge carries one,
1796
+ else `None`.
1797
+ """
1798
+ for value in headers.get_list("www-authenticate"):
1799
+ if _BEARER_SCHEME_RE.search(value) is None:
1800
+ continue
1801
+ match = _RESOURCE_METADATA_RE.search(value)
1802
+ if match is not None:
1803
+ return match.group(1)
1804
+ return None
1805
+
1806
+
1807
+ def find_oauth_challenge(exc: BaseException) -> str | None:
1808
+ """Return the `resource_metadata` URL of a 401 OAuth challenge in `exc`.
1809
+
1810
+ Per the MCP authorization spec (RFC 9728), a server requiring OAuth
1811
+ answers an unauthenticated request with HTTP 401 plus a Bearer
1812
+ `WWW-Authenticate` challenge pointing at its protected-resource metadata.
1813
+ The MCP client surfaces that as an `httpx.HTTPStatusError`. Walks
1814
+ `exceptions` (for `ExceptionGroup`), then `__cause__`/`__context__`,
1815
+ tracking visited nodes to terminate on cyclic chains.
1816
+
1817
+ Args:
1818
+ exc: Root exception to inspect.
1819
+
1820
+ Returns:
1821
+ The `resource_metadata` URL when a 401 response carrying a Bearer
1822
+ challenge is found, else `None`.
1823
+ """
1824
+ visited: set[int] = set()
1825
+ stack: list[BaseException] = [exc]
1826
+ while stack:
1827
+ current = stack.pop()
1828
+ if id(current) in visited:
1829
+ continue
1830
+ visited.add(id(current))
1831
+ if isinstance(current, httpx.HTTPStatusError):
1832
+ response = current.response
1833
+ if (
1834
+ response is not None and response.status_code == 401 # noqa: PLR2004 # HTTP Unauthorized
1835
+ ):
1836
+ challenge = _oauth_resource_challenge(response.headers)
1837
+ if challenge is not None:
1838
+ return challenge
1839
+ if isinstance(current, BaseExceptionGroup):
1840
+ stack.extend(current.exceptions)
1841
+ cause = current.__cause__ or current.__context__
1842
+ if cause is not None:
1843
+ stack.append(cause)
1844
+ return None
1845
+
1846
+
1847
+ async def _drive_handshake(connections: dict) -> None:
1848
+ """Open a one-shot MCP session for `connections` to trigger OAuth handshake."""
1849
+ from langchain_mcp_adapters.client import MultiServerMCPClient
1850
+
1851
+ client = MultiServerMCPClient(connections=connections)
1852
+ server_name = next(iter(connections))
1853
+ async with client.session(server_name):
1854
+ pass
1855
+
1856
+
1857
+ async def login(
1858
+ *,
1859
+ server_name: str,
1860
+ server_config: McpServerSpec,
1861
+ ui: OAuthInteraction,
1862
+ ) -> None:
1863
+ """Drive OAuth login for `server_name`, persisting tokens on success.
1864
+
1865
+ Args:
1866
+ server_name: Name of the configured MCP server.
1867
+ server_config: Parsed server config for that entry.
1868
+ ui: Interaction surface for all user prompts and progress messages
1869
+ during the flow.
1870
+
1871
+ Raises:
1872
+ ValueError: If `server_config` isn't an http/sse server.
1873
+ MCPConfigError: If config env-var interpolation fails or a
1874
+ supported field has the wrong type (a non-string value, or
1875
+ args/env/headers with the wrong container type).
1876
+ RuntimeError: If the device flow fails or times out, or the
1877
+ OAuth handshake aborts.
1878
+ """ # noqa: DOC502 - `RuntimeError` surfaces via the device flow / handshake
1879
+ from langchain_mcp_adapters.sessions import (
1880
+ SSEConnection,
1881
+ StreamableHttpConnection,
1882
+ )
1883
+
1884
+ from deepagents_code.mcp_tools import MCPConfigError, _resolve_server_type
1885
+
1886
+ # OAuth login is discovery-based (RFC 9728), so it works for any remote
1887
+ # http/sse server — whether the config opted in with `auth: oauth` or the
1888
+ # server was auto-detected as needing auth via a 401 challenge. Only the
1889
+ # transport needs gating; stdio servers can't speak OAuth.
1890
+ transport = _resolve_server_type(server_config)
1891
+ if transport not in {"http", "sse"}:
1892
+ msg = (
1893
+ f"Server '{server_name}' uses {transport!r} transport; "
1894
+ "OAuth login is only valid for http/sse."
1895
+ )
1896
+ raise ValueError(msg)
1897
+ try:
1898
+ resolved_config = resolve_mcp_server_env(server_name, server_config)
1899
+ except (RuntimeError, TypeError) as exc:
1900
+ # Re-raise as MCPConfigError (a ValueError) so callers' existing
1901
+ # config-error handling catches it, and `format_login_failure`
1902
+ # preserves the actionable, field-scoped message instead of
1903
+ # collapsing it to a bare "RuntimeError"/"TypeError".
1904
+ raise MCPConfigError(str(exc)) from exc
1905
+
1906
+ from deepagents_code.mcp_providers import resolve_provider
1907
+
1908
+ storage = FileTokenStorage(server_name, server_url=resolved_config["url"])
1909
+ policy = resolve_provider(resolved_config["url"])
1910
+ result = await policy.run_login(
1911
+ server_name=server_name,
1912
+ server_url=resolved_config["url"],
1913
+ storage=storage,
1914
+ ui=ui,
1915
+ )
1916
+
1917
+ success_message = (
1918
+ f"Logged in to MCP server '{server_name}'. Tokens saved to {storage.path}."
1919
+ )
1920
+
1921
+ if result.completed:
1922
+ await ui.show_success(success_message)
1923
+ return
1924
+
1925
+ provider = build_oauth_provider(
1926
+ server_name=server_name,
1927
+ server_url=resolved_config["url"],
1928
+ storage=storage,
1929
+ extra_auth_params=result.extra_auth_params or None,
1930
+ ui=ui,
1931
+ )
1932
+ conn: StreamableHttpConnection | SSEConnection
1933
+ if transport == "http":
1934
+ conn = StreamableHttpConnection(
1935
+ transport="streamable_http",
1936
+ url=resolved_config["url"],
1937
+ auth=provider,
1938
+ )
1939
+ else:
1940
+ conn = SSEConnection(
1941
+ transport="sse",
1942
+ url=resolved_config["url"],
1943
+ auth=provider,
1944
+ )
1945
+
1946
+ if "headers" in resolved_config:
1947
+ conn["headers"] = resolved_config["headers"]
1948
+
1949
+ await _drive_handshake({server_name: conn})
1950
+ await ui.show_success(success_message)