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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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)
|