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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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()