pcode 0.1.0__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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/mcp_notice.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Tell the model which MCP servers are enabled without rewriting its prompt.
|
|
2
|
+
|
|
3
|
+
Tool search hides deferred MCP tools, so nothing in a request says a server
|
|
4
|
+
exists until the model thinks to search, and a model that does not know gdrive
|
|
5
|
+
is enabled reaches for `gcloud` instead. The fix has two halves. A fixed
|
|
6
|
+
instruction says where the list is; it names no server, so it never changes
|
|
7
|
+
during a session. The list itself is appended to history, and only when it
|
|
8
|
+
differs from the last one there, so enabling a server mid-conversation leaves
|
|
9
|
+
earlier messages (and the provider's cached prefix) untouched. The same check
|
|
10
|
+
restores the list after compaction drops it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
|
|
17
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
18
|
+
|
|
19
|
+
from pcode.meridian_reminders import append_reminder, last_reminder
|
|
20
|
+
|
|
21
|
+
TAG = "<mcp-servers>"
|
|
22
|
+
|
|
23
|
+
INSTRUCTIONS = (
|
|
24
|
+
f"Enabled MCP servers are listed in the latest {TAG} message; without one, none "
|
|
25
|
+
"are enabled. Their tools are named mcp_<server>_<tool>, and any not in your tool "
|
|
26
|
+
"list can be found with tool search. Before concluding that an integration or data "
|
|
27
|
+
"source is unavailable, check that list and search for the matching server's tools."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Set per turn by the runtime. A context variable rather than a capability
|
|
31
|
+
# field because the worker is built once, from copies of these capabilities,
|
|
32
|
+
# yet must see the servers enabled for the turn that delegated to it. Both are
|
|
33
|
+
# live views, read at each request: a server can be enabled or disabled while
|
|
34
|
+
# the turn runs, and fail while a step enters it.
|
|
35
|
+
_servers: ContextVar[Callable[[], Mapping[str, str | None]]] = ContextVar(
|
|
36
|
+
"mcp_servers", default=dict
|
|
37
|
+
)
|
|
38
|
+
_unavailable: ContextVar[Mapping[str, str]] = ContextVar("mcp_unavailable", default={})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def enabled_servers(
|
|
43
|
+
servers: Callable[[], Mapping[str, str | None]],
|
|
44
|
+
unavailable: Mapping[str, str] | None = None,
|
|
45
|
+
):
|
|
46
|
+
"""Publish a turn's enabled servers (name -> description) to its agents."""
|
|
47
|
+
token = _servers.set(servers)
|
|
48
|
+
down = _unavailable.set(unavailable if unavailable is not None else {})
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
_unavailable.reset(down)
|
|
53
|
+
_servers.reset(token)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render(servers, unavailable: Mapping[str, str] | None = None) -> str:
|
|
57
|
+
unavailable = unavailable or {}
|
|
58
|
+
if servers:
|
|
59
|
+
lines = "\n".join(
|
|
60
|
+
f"- {name}"
|
|
61
|
+
+ (f": {' '.join(description.split())}" if description else "")
|
|
62
|
+
+ (
|
|
63
|
+
" (failed to connect this turn; its tools are unavailable)"
|
|
64
|
+
if name in unavailable
|
|
65
|
+
else ""
|
|
66
|
+
)
|
|
67
|
+
for name, description in servers
|
|
68
|
+
)
|
|
69
|
+
body = f"Enabled MCP servers (replaces any earlier list):\n{lines}"
|
|
70
|
+
else:
|
|
71
|
+
body = "No MCP servers are enabled now (replaces any earlier list)."
|
|
72
|
+
return f"{TAG}\n{body}\n</mcp-servers>"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MCPServers(AbstractCapability):
|
|
76
|
+
"""The fixed instruction, plus the enabled-server list whenever it changes."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, *, instruct: bool) -> None:
|
|
79
|
+
# Named so /status can attribute the instruction.
|
|
80
|
+
self.id = "mcp_servers"
|
|
81
|
+
self.instruct = instruct
|
|
82
|
+
|
|
83
|
+
def get_instructions(self):
|
|
84
|
+
return INSTRUCTIONS if self.instruct else None
|
|
85
|
+
|
|
86
|
+
async def before_model_request(self, ctx, request_context):
|
|
87
|
+
servers = tuple(_servers.get()().items())
|
|
88
|
+
# "None enabled" only retracts an earlier list; it is never news on its own.
|
|
89
|
+
if servers or last_reminder(request_context.messages, TAG):
|
|
90
|
+
append_reminder(request_context, TAG, render(servers, _unavailable.get()))
|
|
91
|
+
return request_context
|
pcode/mcp_oauth.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Keep native OAuth, but own the loopback socket and the credential file.
|
|
2
|
+
|
|
3
|
+
FastMCP 4.0.4 probes/closes a port at construction, then Uvicorn binds it much
|
|
4
|
+
later. A collision raises SystemExit inside its background task. Reserve the
|
|
5
|
+
actual socket before registration and pass it to an embedded, signal-free server.
|
|
6
|
+
|
|
7
|
+
Tokens and the dynamic client registration outlive the process in one
|
|
8
|
+
owner-only JSON file, like pcode's Anthropic sign-in, so a server can be enabled
|
|
9
|
+
at startup without a browser round trip. No keychain: it locks over SSH, prompts
|
|
10
|
+
after upgrades, and other tools sharing the item have wiped MCP sign-ins.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import socket
|
|
18
|
+
import tempfile
|
|
19
|
+
from contextlib import aclosing, nullcontext
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.parse import parse_qs, parse_qsl, urlencode, urlsplit
|
|
22
|
+
|
|
23
|
+
import anyio
|
|
24
|
+
import httpx2
|
|
25
|
+
import mcp.client.auth.oauth2 as sdk_oauth
|
|
26
|
+
from fastmcp.client.auth import OAuth
|
|
27
|
+
from fastmcp.client.oauth_callback import OAuthCallbackResult, create_oauth_callback_server
|
|
28
|
+
from filelock import FileLock
|
|
29
|
+
from key_value.aio.stores.base import BaseStore
|
|
30
|
+
from mcp.client.auth.utils import issuers_match, validate_metadata_issuer
|
|
31
|
+
from mcp.shared.auth import AuthorizationCodeResult, OAuthMetadata
|
|
32
|
+
from pydantic import AnyHttpUrl
|
|
33
|
+
from starlette.requests import Request
|
|
34
|
+
from starlette.responses import PlainTextResponse
|
|
35
|
+
from uvicorn import Server
|
|
36
|
+
|
|
37
|
+
from pcode.preferences import preferences_path
|
|
38
|
+
|
|
39
|
+
_HOST = "127.0.0.1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SignInRequired(RuntimeError):
|
|
43
|
+
"""The server wants a browser login, which a non-interactive enable must not start."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str) -> None:
|
|
47
|
+
"""Accept a root issuer written with or without its trailing slash.
|
|
48
|
+
|
|
49
|
+
The SDK treats `https://host` and `https://host/` as one server when binding
|
|
50
|
+
stored credentials and on its legacy discovery path, but compares strictly
|
|
51
|
+
when resource metadata names the server. Google's MCP servers name
|
|
52
|
+
`https://accounts.google.com/` there while Google's metadata says
|
|
53
|
+
`https://accounts.google.com`, so every sign-in failed. Any other mismatch
|
|
54
|
+
still fails the SDK's own check.
|
|
55
|
+
"""
|
|
56
|
+
if not issuers_match(str(oauth_metadata.issuer), expected_issuer):
|
|
57
|
+
validate_metadata_issuer(oauth_metadata, expected_issuer)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
sdk_oauth.validate_metadata_issuer = _validate_metadata_issuer
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def credentials_path() -> Path:
|
|
64
|
+
return preferences_path().with_name("mcp-credentials.json")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CredentialStore(BaseStore):
|
|
68
|
+
"""FastMCP's AsyncKeyValue over one 0600 JSON file, keyed by server URL.
|
|
69
|
+
|
|
70
|
+
Every operation re-reads the file under a lock, so concurrent pcode processes
|
|
71
|
+
see each other's refreshed tokens instead of clobbering them with stale ones.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
75
|
+
super().__init__(stable_api=True)
|
|
76
|
+
self._path = path or credentials_path()
|
|
77
|
+
self._lock = FileLock(f"{self._path}.lock", mode=0o600)
|
|
78
|
+
|
|
79
|
+
def _read(self) -> dict:
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return {}
|
|
84
|
+
except (OSError, ValueError):
|
|
85
|
+
raise RuntimeError(f"Cannot read MCP credentials: {self._path}") from None
|
|
86
|
+
return data if isinstance(data, dict) else {}
|
|
87
|
+
|
|
88
|
+
def _write(self, data: dict) -> None:
|
|
89
|
+
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
90
|
+
name = None
|
|
91
|
+
try:
|
|
92
|
+
with tempfile.NamedTemporaryFile(
|
|
93
|
+
mode="w", dir=self._path.parent, delete=False, encoding="utf-8"
|
|
94
|
+
) as file:
|
|
95
|
+
name = file.name
|
|
96
|
+
# Restrict before any token bytes reach the filesystem.
|
|
97
|
+
os.chmod(file.fileno(), 0o600)
|
|
98
|
+
json.dump(data, file, indent=2)
|
|
99
|
+
file.write("\n")
|
|
100
|
+
os.replace(name, self._path)
|
|
101
|
+
name = None
|
|
102
|
+
finally:
|
|
103
|
+
if name is not None:
|
|
104
|
+
os.unlink(name)
|
|
105
|
+
|
|
106
|
+
async def _get_managed_entry(self, *, collection: str, key: str):
|
|
107
|
+
with self._lock:
|
|
108
|
+
raw = self._read().get(collection, {}).get(key)
|
|
109
|
+
if not isinstance(raw, dict):
|
|
110
|
+
return None
|
|
111
|
+
return self._serialization_adapter.load_dict(data=raw)
|
|
112
|
+
|
|
113
|
+
async def _put_managed_entry(self, *, collection: str, key: str, managed_entry) -> None:
|
|
114
|
+
with self._lock:
|
|
115
|
+
data = self._read()
|
|
116
|
+
data.setdefault(collection, {})[key] = self._serialization_adapter.dump_dict(
|
|
117
|
+
entry=managed_entry
|
|
118
|
+
)
|
|
119
|
+
self._write(data)
|
|
120
|
+
|
|
121
|
+
async def _delete_managed_entry(self, *, key: str, collection: str) -> bool:
|
|
122
|
+
with self._lock:
|
|
123
|
+
data = self._read()
|
|
124
|
+
removed = data.get(collection, {}).pop(key, None) is not None
|
|
125
|
+
if removed:
|
|
126
|
+
self._write(data)
|
|
127
|
+
return removed
|
|
128
|
+
|
|
129
|
+
async def _get_collection_keys(self, *, collection: str, limit: int | None = None) -> list[str]:
|
|
130
|
+
with self._lock:
|
|
131
|
+
keys = list(self._read().get(collection, {}))
|
|
132
|
+
return keys[:limit] if limit is not None else keys
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _CallbackServer(Server):
|
|
136
|
+
def capture_signals(self):
|
|
137
|
+
# This is a component of pcode, not a process-wide web server. Ctrl+C must
|
|
138
|
+
# keep reaching pcode's cancellation handler, even with multiple MCP clients.
|
|
139
|
+
return nullcontext()
|
|
140
|
+
|
|
141
|
+
async def serve(self, sockets=None):
|
|
142
|
+
try:
|
|
143
|
+
await super().serve(sockets=sockets)
|
|
144
|
+
except SystemExit:
|
|
145
|
+
# Catch inside the spawned task: catching outside a TaskGroup is too
|
|
146
|
+
# late to stop asyncio treating SystemExit as process termination.
|
|
147
|
+
raise RuntimeError("MCP OAuth callback server could not start.") from None
|
|
148
|
+
finally:
|
|
149
|
+
for listener in getattr(self, "servers", []):
|
|
150
|
+
listener.close()
|
|
151
|
+
await listener.wait_closed()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _single_token_auth(request: httpx2.Request) -> httpx2.Request:
|
|
155
|
+
"""Keep Basic credentials exclusively in the header of native token requests.
|
|
156
|
+
|
|
157
|
+
The SDK removes client_secret for Basic auth but leaves client_id in the
|
|
158
|
+
form, which strict servers reject as a second authentication method. Limit
|
|
159
|
+
this workaround to token builders; resource requests remain untouched.
|
|
160
|
+
"""
|
|
161
|
+
if request.headers.get("Authorization", "").partition(" ")[0].lower() != "basic":
|
|
162
|
+
return request
|
|
163
|
+
form = parse_qsl(request.content.decode(), keep_blank_values=True)
|
|
164
|
+
filtered = [(key, value) for key, value in form if key not in {"client_id", "client_secret"}]
|
|
165
|
+
if filtered == form:
|
|
166
|
+
return request
|
|
167
|
+
headers = request.headers.copy()
|
|
168
|
+
del headers["Content-Length"]
|
|
169
|
+
return httpx2.Request(
|
|
170
|
+
request.method,
|
|
171
|
+
request.url,
|
|
172
|
+
content=urlencode(filtered).encode(),
|
|
173
|
+
headers=headers,
|
|
174
|
+
extensions=request.extensions,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class LoopbackOAuth(OAuth):
|
|
179
|
+
"""FastMCP handles protocol/security; this adapter owns callback I/O lifetime."""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
store: CredentialStore | None = None,
|
|
184
|
+
*,
|
|
185
|
+
interactive: bool = True,
|
|
186
|
+
client_id: str | None = None,
|
|
187
|
+
client_secret: str | None = None,
|
|
188
|
+
):
|
|
189
|
+
super().__init__(
|
|
190
|
+
client_name="pcode",
|
|
191
|
+
callback_host=_HOST,
|
|
192
|
+
token_storage=store or CredentialStore(),
|
|
193
|
+
client_id=client_id,
|
|
194
|
+
client_secret=client_secret,
|
|
195
|
+
)
|
|
196
|
+
self.interactive = interactive
|
|
197
|
+
# Only a dynamic registration pins a callback port. A client created
|
|
198
|
+
# ahead of time may use any loopback port (RFC 8252 section 7.3), so the
|
|
199
|
+
# socket pcode reserves decides, not a port saved by an earlier process.
|
|
200
|
+
self._preregistered = client_id is not None
|
|
201
|
+
self._callback_socket: socket.socket | None = None
|
|
202
|
+
self._flow_lock = asyncio.Lock()
|
|
203
|
+
self._expected_state: str | None = None
|
|
204
|
+
# The error that ended the latest sign-in attempt, until a request gets
|
|
205
|
+
# through. A call that triggers it sees only "Connection closed" from the
|
|
206
|
+
# MCP session, so the caller reads the real cause here (see
|
|
207
|
+
# `NamedServer.call_tool`). Shared by concurrent calls, so never cleared on
|
|
208
|
+
# entry or on read.
|
|
209
|
+
self.failure: Exception | None = None
|
|
210
|
+
|
|
211
|
+
async def _exchange_token_authorization_code(
|
|
212
|
+
self, auth_code: str, code_verifier: str
|
|
213
|
+
) -> httpx2.Request:
|
|
214
|
+
return _single_token_auth(
|
|
215
|
+
await super()._exchange_token_authorization_code(auth_code, code_verifier)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def _refresh_token(self) -> httpx2.Request:
|
|
219
|
+
return _single_token_auth(await super()._refresh_token())
|
|
220
|
+
|
|
221
|
+
def _adopt_registered_port(self, registered) -> int:
|
|
222
|
+
"""A registration from an earlier process names its own callback port; the
|
|
223
|
+
authorization and token requests must present that same redirect URI."""
|
|
224
|
+
if registered is not None and registered.redirect_uris:
|
|
225
|
+
port = registered.redirect_uris[0].port
|
|
226
|
+
if port and port != self.redirect_port:
|
|
227
|
+
self.redirect_port = port
|
|
228
|
+
self.context.client_metadata.redirect_uris = [
|
|
229
|
+
AnyHttpUrl(f"http://{_HOST}:{port}/callback")
|
|
230
|
+
]
|
|
231
|
+
return self.redirect_port
|
|
232
|
+
|
|
233
|
+
async def _initialize(self) -> None:
|
|
234
|
+
# The SDK builds the authorization URL from client_metadata before calling
|
|
235
|
+
# redirect_handler, so the saved registration's port must be adopted here.
|
|
236
|
+
await super()._initialize()
|
|
237
|
+
if not self._preregistered:
|
|
238
|
+
self._adopt_registered_port(self.context.client_info)
|
|
239
|
+
|
|
240
|
+
async def _reserve_callback(self) -> socket.socket:
|
|
241
|
+
if self._callback_socket is not None:
|
|
242
|
+
return self._callback_socket
|
|
243
|
+
registered = (
|
|
244
|
+
None if self._preregistered else await self.token_storage_adapter.get_client_info()
|
|
245
|
+
)
|
|
246
|
+
port = self._adopt_registered_port(registered)
|
|
247
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
248
|
+
try:
|
|
249
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
250
|
+
try:
|
|
251
|
+
sock.bind((_HOST, port))
|
|
252
|
+
except OSError:
|
|
253
|
+
if registered is not None:
|
|
254
|
+
# A registered redirect URI cannot be silently changed. Forget
|
|
255
|
+
# the registration (its tokens are already unusable here) so the
|
|
256
|
+
# next attempt registers afresh on a free port.
|
|
257
|
+
await self.token_storage_adapter.clear()
|
|
258
|
+
self.context.client_info = None
|
|
259
|
+
raise RuntimeError(
|
|
260
|
+
"MCP OAuth callback port is unavailable. Retry sign-in."
|
|
261
|
+
) from None
|
|
262
|
+
sock.bind((_HOST, 0))
|
|
263
|
+
sock.listen()
|
|
264
|
+
sock.setblocking(False)
|
|
265
|
+
self.redirect_port = sock.getsockname()[1]
|
|
266
|
+
self.context.client_metadata.redirect_uris = [
|
|
267
|
+
AnyHttpUrl(f"http://{_HOST}:{self.redirect_port}/callback")
|
|
268
|
+
]
|
|
269
|
+
self._callback_socket = sock
|
|
270
|
+
return sock
|
|
271
|
+
except BaseException:
|
|
272
|
+
sock.close()
|
|
273
|
+
raise
|
|
274
|
+
|
|
275
|
+
def _close_callback(self) -> None:
|
|
276
|
+
self._expected_state = None
|
|
277
|
+
if self._callback_socket is not None:
|
|
278
|
+
self._callback_socket.close()
|
|
279
|
+
self._callback_socket = None
|
|
280
|
+
|
|
281
|
+
async def async_auth_flow(self, request):
|
|
282
|
+
# The SDK already serializes its protocol flow. Include our reservation
|
|
283
|
+
# in that lifetime too, so concurrent requests cannot change redirect URIs.
|
|
284
|
+
async with self._flow_lock:
|
|
285
|
+
try:
|
|
286
|
+
if await self.token_storage_adapter.get_tokens() is None:
|
|
287
|
+
await self._reserve_callback()
|
|
288
|
+
async with aclosing(super().async_auth_flow(request)) as flow:
|
|
289
|
+
try:
|
|
290
|
+
outgoing = await anext(flow)
|
|
291
|
+
while True:
|
|
292
|
+
response = yield outgoing
|
|
293
|
+
outgoing = await flow.asend(response)
|
|
294
|
+
except StopAsyncIteration:
|
|
295
|
+
self.failure = None
|
|
296
|
+
return
|
|
297
|
+
except Exception as error:
|
|
298
|
+
self.failure = error
|
|
299
|
+
raise
|
|
300
|
+
finally:
|
|
301
|
+
self._close_callback()
|
|
302
|
+
|
|
303
|
+
async def redirect_handler(self, authorization_url: str) -> None:
|
|
304
|
+
if not self.interactive:
|
|
305
|
+
raise SignInRequired("This MCP server needs a browser sign-in.")
|
|
306
|
+
# Refresh can fail and require a new login even when the flow began with
|
|
307
|
+
# tokens. Verify socket ownership before opening a browser in that case.
|
|
308
|
+
await self._reserve_callback()
|
|
309
|
+
states = parse_qs(urlsplit(authorization_url).query).get("state", [])
|
|
310
|
+
if len(states) != 1 or not states[0]:
|
|
311
|
+
raise RuntimeError("MCP authorization URL has no unique state parameter.")
|
|
312
|
+
self._expected_state = states[0]
|
|
313
|
+
await super().redirect_handler(authorization_url)
|
|
314
|
+
|
|
315
|
+
async def callback_handler(self) -> AuthorizationCodeResult:
|
|
316
|
+
sock = await self._reserve_callback()
|
|
317
|
+
try:
|
|
318
|
+
result = OAuthCallbackResult()
|
|
319
|
+
ready = anyio.Event()
|
|
320
|
+
stopped = anyio.Event()
|
|
321
|
+
server = _CallbackServer(
|
|
322
|
+
create_oauth_callback_server(
|
|
323
|
+
port=self.redirect_port,
|
|
324
|
+
host=_HOST,
|
|
325
|
+
server_url=self.mcp_url,
|
|
326
|
+
result_container=result,
|
|
327
|
+
result_ready=ready,
|
|
328
|
+
).config
|
|
329
|
+
)
|
|
330
|
+
callback_app = server.config.app
|
|
331
|
+
expected_state = self._expected_state
|
|
332
|
+
|
|
333
|
+
async def checked_callback(scope, receive, send):
|
|
334
|
+
if scope["type"] == "http" and scope["path"] == "/callback":
|
|
335
|
+
states = Request(scope).query_params.getlist("state")
|
|
336
|
+
if (
|
|
337
|
+
expected_state is None
|
|
338
|
+
or len(states) != 1
|
|
339
|
+
or not secrets.compare_digest(states[0].encode(), expected_state.encode())
|
|
340
|
+
):
|
|
341
|
+
# A late redirect from an earlier attempt must not claim
|
|
342
|
+
# this listener or cancel the current sign-in. The SDK
|
|
343
|
+
# still validates state, issuer, and PKCE after this gate.
|
|
344
|
+
response = PlainTextResponse(
|
|
345
|
+
"This callback does not match the current sign-in. "
|
|
346
|
+
"Close this tab and use the newest sign-in tab.",
|
|
347
|
+
status_code=400,
|
|
348
|
+
headers={"Cache-Control": "no-store"},
|
|
349
|
+
)
|
|
350
|
+
await response(scope, receive, send)
|
|
351
|
+
return
|
|
352
|
+
await callback_app(scope, receive, send)
|
|
353
|
+
|
|
354
|
+
server.config.app = checked_callback
|
|
355
|
+
server.config.timeout_graceful_shutdown = 1
|
|
356
|
+
|
|
357
|
+
async def serve():
|
|
358
|
+
try:
|
|
359
|
+
await server.serve(sockets=[sock])
|
|
360
|
+
finally:
|
|
361
|
+
stopped.set()
|
|
362
|
+
|
|
363
|
+
async with anyio.create_task_group() as group:
|
|
364
|
+
group.start_soon(serve)
|
|
365
|
+
try:
|
|
366
|
+
with anyio.fail_after(self._callback_timeout):
|
|
367
|
+
await ready.wait()
|
|
368
|
+
if result.error:
|
|
369
|
+
raise result.error
|
|
370
|
+
return AuthorizationCodeResult(
|
|
371
|
+
code=result.code, state=result.state, iss=result.iss
|
|
372
|
+
)
|
|
373
|
+
finally:
|
|
374
|
+
server.should_exit = True
|
|
375
|
+
# Shield cleanup from the cancelled model turn. Bound the wait
|
|
376
|
+
# and explicitly close listeners even if startup/shutdown fails.
|
|
377
|
+
with anyio.move_on_after(2, shield=True):
|
|
378
|
+
await stopped.wait()
|
|
379
|
+
group.cancel_scope.cancel()
|
|
380
|
+
finally:
|
|
381
|
+
self._close_callback()
|