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.py ADDED
@@ -0,0 +1,683 @@
1
+ """Explicit, conversation-scoped MCP activation; configuration alone does nothing."""
2
+
3
+ import functools
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import sys
9
+ from collections.abc import Callable
10
+ from contextlib import AsyncExitStack
11
+ from dataclasses import replace
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+ from urllib.parse import urlsplit
15
+
16
+ from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
17
+
18
+ from pcode.preferences import preferences_path
19
+
20
+ _NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,31}\Z")
21
+ _ENV = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
22
+ # Packages whose exceptions mean the OAuth flow itself failed.
23
+ OAUTH_PACKAGES = ("mcp.client.auth", "fastmcp.client.auth", "pcode.mcp_oauth")
24
+ STDIO_INIT_TIMEOUT = 30.0 # seconds from launch until the server answers its handshake
25
+
26
+
27
+ class MCPConnectError(RuntimeError):
28
+ """An enabled server failed to connect: names it, with the HTTP status if any.
29
+
30
+ The message holds only fixed text, the server name, and the config path,
31
+ never anything the server sent, so it is safe to show as-is.
32
+ """
33
+
34
+ def __init__(self, server: str, message: str):
35
+ super().__init__(message)
36
+ self.server = server
37
+
38
+
39
+ def mcp_transport(toolset: Any) -> Any:
40
+ """The client transport of the MCPToolset inside our prefix/defer wrappers."""
41
+ while (wrapped := getattr(toolset, "wrapped", None)) is not None:
42
+ toolset = wrapped
43
+ return getattr(getattr(toolset, "client", None), "transport", None)
44
+
45
+
46
+ def _without_deferred_loading(toolset: Any) -> Any | None:
47
+ """Rebuild a toolset without its deferred-loading layer, or `None` if it has none.
48
+
49
+ Only the wrappers are rebuilt: the `MCPToolset` itself (with its client, its
50
+ OAuth object and whatever tokens it already holds) is the same instance, so
51
+ dropping deferral costs neither a reconnection nor a second sign-in.
52
+ """
53
+ from pydantic_ai.toolsets.deferred_loading import DeferredLoadingToolset
54
+
55
+ if isinstance(toolset, DeferredLoadingToolset):
56
+ return toolset.wrapped
57
+ wrapped = getattr(toolset, "wrapped", None)
58
+ if wrapped is None:
59
+ return None
60
+ inner = _without_deferred_loading(wrapped)
61
+ return None if inner is None else replace(toolset, wrapped=inner)
62
+
63
+
64
+ def config_path() -> Path:
65
+ override = os.environ.get("PCODE_MCP_CONFIG", "").strip()
66
+ return Path(override).expanduser() if override else preferences_path().with_name("mcp.json")
67
+
68
+
69
+ def configured_servers() -> dict[str, Any]:
70
+ """Read names without importing MCP, expanding secrets, or starting servers."""
71
+ path = config_path()
72
+ try:
73
+ data = json.loads(path.read_text())
74
+ except FileNotFoundError:
75
+ if not os.environ.get("PCODE_MCP_CONFIG", "").strip():
76
+ return {}
77
+ raise ValueError(f"MCP configuration not found: {path}") from None
78
+ except (OSError, ValueError):
79
+ raise ValueError(f"Cannot read MCP configuration: {path} (expected JSON)") from None
80
+ return _servers(data)
81
+
82
+
83
+ def _servers(data: Any) -> dict[str, Any]:
84
+ if not isinstance(data, dict) or not isinstance(data.get("mcpServers"), dict):
85
+ raise ValueError("MCP configuration must contain an mcpServers object.")
86
+ servers = data["mcpServers"]
87
+ if any(not _NAME.fullmatch(name) for name in servers):
88
+ raise ValueError(
89
+ "MCP server names must start with a letter and contain only letters, digits, "
90
+ "underscores or hyphens (up to 32 characters)."
91
+ )
92
+ return servers
93
+
94
+
95
+ def default_servers() -> list[str]:
96
+ """Names marked `"enabled": true`, without validating or expanding the rest."""
97
+ return sorted(
98
+ name
99
+ for name, raw in configured_servers().items()
100
+ if isinstance(raw, dict) and raw.get("enabled") is True
101
+ )
102
+
103
+
104
+ def save_default(name: str, enabled: bool) -> None:
105
+ """Set or clear a server's `"enabled": true`, leaving the rest of the file as is.
106
+
107
+ Off is the default, so turning one off drops the key rather than writing false.
108
+ """
109
+ from filelock import FileLock
110
+
111
+ from pcode.preferences import write_json
112
+
113
+ # Write through a symlink (a dotfile-managed config) rather than replacing it.
114
+ path = config_path().resolve()
115
+ with FileLock(str(path) + ".lock", timeout=5):
116
+ try:
117
+ data = json.loads(path.read_text())
118
+ except (OSError, ValueError):
119
+ raise ValueError(f"Cannot read MCP configuration: {path} (expected JSON)") from None
120
+ server = _servers(data).get(name)
121
+ if not isinstance(server, dict):
122
+ raise ValueError(f"Unknown MCP server '{name}'. Use /mcp list.")
123
+ if enabled:
124
+ server["enabled"] = True
125
+ else:
126
+ server.pop("enabled", None)
127
+ write_json(path, data)
128
+
129
+
130
+ def _expand(value: Any) -> Any:
131
+ if isinstance(value, str):
132
+
133
+ def replace(match: re.Match) -> str:
134
+ name, default = match.groups()
135
+ if name in os.environ:
136
+ return os.environ[name]
137
+ if default is not None:
138
+ return default
139
+ raise ValueError(f"Missing MCP environment variable: {name}")
140
+
141
+ return _ENV.sub(replace, value)
142
+ if isinstance(value, list):
143
+ return [_expand(item) for item in value]
144
+ if isinstance(value, dict):
145
+ return {key: _expand(item) for key, item in value.items()}
146
+ return value
147
+
148
+
149
+ class ServerConfig(BaseModel):
150
+ model_config = ConfigDict(extra="forbid", strict=True)
151
+
152
+ command: str | None = None
153
+ args: list[str] | None = None
154
+ env: dict[str, str] | None = None
155
+ cwd: str | None = None
156
+ url: str | None = None
157
+ headers: dict[str, str] | None = None
158
+ auth: Literal["oauth"] | None = None
159
+ # A client created with the provider ahead of time, for services without
160
+ # dynamic client registration (Google's MCP servers, for one).
161
+ client_id: str | None = None
162
+ client_secret: str | None = None
163
+ # Enable at startup, /new, and resume instead of waiting for /mcp enable.
164
+ enabled: bool = False
165
+ # Off by default: a server's schemas otherwise sit in every request of the
166
+ # conversation, while tool search costs one call for the tools actually used.
167
+ direct: bool = False
168
+ # What the server is for, shown to the model beside its name in the list of
169
+ # enabled servers. Only worth setting when the name does not already say it.
170
+ description: str | None = None
171
+
172
+ @model_validator(mode="after")
173
+ def transport(self):
174
+ if bool(self.command) == bool(self.url):
175
+ raise ValueError("Specify exactly one of command or url.")
176
+ if self.client_secret is not None and self.client_id is None:
177
+ raise ValueError("client_secret needs client_id.")
178
+ if self.client_id is not None and (self.auth != "oauth" or not self.client_id.strip()):
179
+ raise ValueError('client_id needs auth: "oauth".')
180
+ if self.command:
181
+ if not self.command.strip() or any(
182
+ item is not None
183
+ for item in (self.headers, self.auth, self.client_id, self.client_secret)
184
+ ):
185
+ raise ValueError("Invalid stdio options.")
186
+ return self
187
+ if any(item is not None for item in (self.command, self.args, self.env, self.cwd)):
188
+ raise ValueError("Invalid HTTP options.")
189
+ parsed = urlsplit(self.url)
190
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
191
+ raise ValueError("Expected an HTTP(S) URL.")
192
+ if self.auth and any(key.lower() == "authorization" for key in self.headers or {}):
193
+ raise ValueError("OAuth cannot be combined with an Authorization header.")
194
+ return self
195
+
196
+
197
+ def _config(name: str, raw: Any) -> ServerConfig:
198
+ try:
199
+ return ServerConfig.model_validate(_expand(raw))
200
+ except ValidationError:
201
+ # Pydantic errors include input values: never print credentials from config.
202
+ raise ValueError(
203
+ f"Invalid MCP server '{name}'. Use command/args/env/cwd for stdio or url/headers "
204
+ 'for HTTP (optional auth: "oauth" with client_id/client_secret, enabled: true, '
205
+ "direct: true, description); "
206
+ "other fields are not supported."
207
+ ) from None
208
+
209
+
210
+ def _connect_failure(
211
+ server: str, config: ServerConfig, status: int | None, oauth_challenge: bool, error: Exception
212
+ ) -> str:
213
+ if status is None:
214
+ return f"MCP server '{server}' failed to connect ({type(error).__name__})."
215
+ from http import HTTPStatus
216
+
217
+ try:
218
+ reason = f" {HTTPStatus(status).phrase}"
219
+ except ValueError:
220
+ reason = ""
221
+ # A config edit reaches a server only when it is enabled again.
222
+ entry = f"its entry in {config_path()}"
223
+ then = f", then run `/mcp disable {server}` and `/mcp enable {server}`"
224
+ authorization = any(key.lower() == "authorization" for key in config.headers or {})
225
+ if status in (401, 403):
226
+ if config.auth == "oauth":
227
+ hint = f"Sign in again: `/mcp logout {server}`, then `/mcp enable {server}`."
228
+ elif oauth_challenge and not authorization:
229
+ hint = f'It offers OAuth sign-in: add "auth": "oauth" to {entry}{then}.'
230
+ elif config.headers:
231
+ hint = f"Check the credentials in the headers of {entry}{then}."
232
+ else:
233
+ hint = f"It needs credentials: add an Authorization header to {entry}{then}."
234
+ elif status == 404:
235
+ hint = f"Check the url in {entry}{then}."
236
+ elif status >= 500:
237
+ hint = "The server itself failed; try again later."
238
+ else:
239
+ hint = ""
240
+ return f"MCP server '{server}' failed to connect: HTTP {status}{reason}. {hint}".strip()
241
+
242
+
243
+ @functools.cache
244
+ def _recording_transport() -> type:
245
+ """A streamable HTTP transport that keeps its endpoint's last HTTP error.
246
+
247
+ The MCP SDK reduces any failed response but a 404 to a bare `MCPError`
248
+ ("Server returned an error response"), dropping the status. FastMCP
249
+ registers `_capture_session_id` as a response hook on every client it
250
+ builds, whatever the factory, so extending it sees each response.
251
+ """
252
+ import httpx2
253
+ from fastmcp.client.transports import StreamableHttpTransport
254
+
255
+ class RecordingTransport(StreamableHttpTransport):
256
+ failed_status: int | None = None
257
+ oauth_challenge = False
258
+
259
+ async def _capture_session_id(self, response: httpx2.Response) -> None:
260
+ await super()._capture_session_id(response)
261
+ request = response.request
262
+ # The OAuth flow's discovery, registration, and token requests travel
263
+ # the same client; only MCP messages to the endpoint itself count.
264
+ if request.method != "POST" or request.url != httpx2.URL(self.url):
265
+ return
266
+ failed = response.status_code >= 400
267
+ self.failed_status = response.status_code if failed else None
268
+ challenge = response.headers.get("www-authenticate", "") if failed else ""
269
+ self.oauth_challenge = "resource_metadata=" in challenge
270
+
271
+ return RecordingTransport
272
+
273
+
274
+ @functools.cache
275
+ def _named_toolset() -> type:
276
+ from dataclasses import dataclass
277
+
278
+ from pydantic_ai.toolsets import WrapperToolset
279
+
280
+ @dataclass
281
+ class NamedServer(WrapperToolset):
282
+ """Says which server failed to connect; the SDK's errors never do."""
283
+
284
+ server: str = ""
285
+ config: ServerConfig | None = None
286
+
287
+ async def __aenter__(self):
288
+ transport = mcp_transport(self.wrapped)
289
+ if hasattr(transport, "failed_status"):
290
+ transport.failed_status = None
291
+ try:
292
+ return await super().__aenter__()
293
+ except Exception as error:
294
+ status = getattr(transport, "failed_status", None)
295
+ # An OAuth failure has its own cause; the endpoint's 401 is just
296
+ # the challenge that started the flow.
297
+ if find_cause(error, _from_packages(OAUTH_PACKAGES)) is not None:
298
+ status = None
299
+ challenge = getattr(transport, "oauth_challenge", False)
300
+ message = _connect_failure(self.server, self.config, status, challenge, error)
301
+ raise MCPConnectError(self.server, message) from error
302
+
303
+ async def call_tool(self, name, tool_args, ctx, tool):
304
+ from pydantic_ai.exceptions import ModelRetry
305
+
306
+ auth = getattr(mcp_transport(self.wrapped), "auth", None)
307
+ try:
308
+ return await super().call_tool(name, tool_args, ctx, tool)
309
+ except ModelRetry as error:
310
+ # A server that lists tools without sign-in can demand it on the
311
+ # first call. When that sign-in fails the session closes, and the
312
+ # model is told only "Connection closed", which it retries as a
313
+ # hiccup. Fail the turn with the sign-in error instead. Network
314
+ # errors during the flow stay retries.
315
+ failure = getattr(auth, "failure", None)
316
+ if failure is None or find_cause(failure, _from_packages(OAUTH_PACKAGES)) is None:
317
+ raise
318
+ raise failure from error
319
+
320
+ return NamedServer
321
+
322
+
323
+ def build_toolset(name: str, raw: Any, *, interactive: bool = True):
324
+ """Construct only the selected server. Connections are owned by each agent run.
325
+
326
+ A non-interactive OAuth server may use stored tokens and refresh them, but
327
+ fails with SignInRequired rather than opening a browser.
328
+ """
329
+ config = _config(name, raw)
330
+ from fastmcp.client.transports import StdioTransport
331
+ from pydantic_ai.mcp import MCPToolset
332
+
333
+ try:
334
+ if config.command:
335
+ transport = StdioTransport(
336
+ command=config.command,
337
+ args=config.args or [],
338
+ env=config.env,
339
+ cwd=config.cwd,
340
+ # FastMCP otherwise keeps subprocesses alive after toolset exit.
341
+ keep_alive=False,
342
+ )
343
+ # FastMCP's five-second handshake deadline counts the server's own
344
+ # startup, which a `uvx`/`npx` launch beside other servers can exceed.
345
+ toolset = MCPToolset(transport, id=name, init_timeout=STDIO_INIT_TIMEOUT)
346
+ else:
347
+ # FastMCP owns PKCE, browser sign-in, and refresh; pcode owns the socket
348
+ # and the credential file (see mcp_oauth).
349
+ from pcode.mcp_oauth import LoopbackOAuth
350
+
351
+ auth = (
352
+ LoopbackOAuth(
353
+ interactive=interactive,
354
+ client_id=config.client_id,
355
+ client_secret=config.client_secret,
356
+ )
357
+ if config.auth == "oauth"
358
+ else None
359
+ )
360
+ from fastmcp.mcp_config import infer_transport_type_from_url
361
+
362
+ if infer_transport_type_from_url(config.url) == "sse":
363
+ server, http = config.url, {"headers": config.headers, "auth": auth}
364
+ else:
365
+ server = _recording_transport()(config.url, headers=config.headers, auth=auth)
366
+ http = {}
367
+ toolset = MCPToolset(
368
+ server,
369
+ id=name,
370
+ **http,
371
+ # The default five-second handshake deadline also covers OAuth.
372
+ # Give interactive sign-in a bounded five-minute window instead.
373
+ **({"init_timeout": 300} if auth is not None else {}),
374
+ )
375
+ toolset = _named_toolset()(toolset, server=name, config=config)
376
+ # Hidden until Pydantic AI's auto-injected ToolSearch reveals them, so a
377
+ # server's schemas cost one `search_tools` call instead of every request.
378
+ if not config.direct:
379
+ toolset = toolset.defer_loading()
380
+ return toolset.prefixed(f"mcp_{name}")
381
+ except (ValueError, TypeError):
382
+ raise ValueError(
383
+ f"Cannot configure MCP server '{name}'; check its transport options."
384
+ ) from None
385
+
386
+
387
+ def deferred_schemas_rejected(error: BaseException) -> bool:
388
+ """Whether the provider rejected this request's hidden tool schemas.
389
+
390
+ Deferred loading is a request-shape choice, not history: a provider that
391
+ will not pair withheld schemas with its tool search rejects every request
392
+ the same way, so the session is stuck until the tools are sent in full.
393
+ Narrow on purpose -- a 400 naming the search surface is the provider saying
394
+ this pairing is impossible; any other 400 is a request pcode built wrong.
395
+ """
396
+ from pydantic_ai.exceptions import ModelHTTPError
397
+
398
+ return (
399
+ isinstance(error, ModelHTTPError)
400
+ and error.status_code == 400
401
+ and ("tool_search" in str(error.body) or "defer_loading" in str(error.body))
402
+ )
403
+
404
+
405
+ def _from_packages(packages: tuple[str, ...]):
406
+ def raised_there(error: BaseException) -> bool:
407
+ module = type(error).__module__
408
+ return any(module == pkg or module.startswith(f"{pkg}.") for pkg in packages)
409
+
410
+ return raised_there
411
+
412
+
413
+ def find_cause(error: BaseException, kind) -> BaseException | None:
414
+ """The first error in the chain that is a `kind`, or that `kind(error)` accepts."""
415
+ matches = kind if not isinstance(kind, type) else lambda current: isinstance(current, kind)
416
+ seen: set[int] = set()
417
+ pending = [error]
418
+ while pending:
419
+ current = pending.pop()
420
+ if current is None or id(current) in seen:
421
+ continue
422
+ seen.add(id(current))
423
+ if matches(current):
424
+ return current
425
+ pending.extend([current.__cause__, current.__context__])
426
+ pending.extend(getattr(current, "exceptions", ()))
427
+ return None
428
+
429
+
430
+ def error_message(error: BaseException) -> str:
431
+ """Show redacted MCP causes, not model/provider troubleshooting advice."""
432
+ from pcode.diagnostics import error_details
433
+
434
+ detail = error_details(error)
435
+ messages = []
436
+ while detail:
437
+ message = " ".join(str(detail.get("message", "")).split())
438
+ summary = detail["type"] + (f": {message}" if message else "")
439
+ if summary not in messages:
440
+ messages.append(summary)
441
+ detail = detail.get("cause", detail.get("context", {}))
442
+ return (
443
+ "MCP connection or sign-in failed. "
444
+ + " Caused by: ".join(messages)[:2000]
445
+ + " Check the MCP server configuration, authentication, and connectivity."
446
+ )
447
+
448
+
449
+ @functools.cache
450
+ def _isolated() -> type:
451
+ """A per-server layer that turns a failed connection into an empty toolset.
452
+
453
+ pydantic-ai enters every run toolset in one exit stack, so one server whose
454
+ handshake fails would otherwise fail the whole turn and take every other
455
+ tool, built-in or MCP, down with it. The same holds for a connection that
456
+ drops later: listing its tools, or closing it when the run ends, raises
457
+ from inside the run, so both degrade the same way the handshake does.
458
+ """
459
+ from dataclasses import dataclass, field
460
+
461
+ from pydantic_ai.toolsets import WrapperToolset
462
+
463
+ @dataclass
464
+ class IsolatedServer(WrapperToolset):
465
+ server: str = ""
466
+ state: Any = None
467
+ # Per instance: exit only what this instance entered.
468
+ _entered: bool = field(default=False, init=False, compare=False, repr=False)
469
+
470
+ async def __aenter__(self):
471
+ try:
472
+ await self.wrapped.__aenter__()
473
+ except Exception as error: # CancelledError is not an Exception.
474
+ self.state.connect_failed(self.server, error)
475
+ else:
476
+ self._entered = True
477
+ self.state.unavailable.pop(self.server, None)
478
+ return self
479
+
480
+ async def __aexit__(self, *args: Any) -> bool | None:
481
+ if not self._entered:
482
+ return None
483
+ self._entered = False
484
+ try:
485
+ return await self.wrapped.__aexit__(*args)
486
+ except Exception:
487
+ # The turn is already over; a server that went away meanwhile
488
+ # must not replace its result (or its real error) with this one.
489
+ logging.getLogger(__name__).debug(
490
+ "MCP server %r failed to disconnect", self.server, exc_info=True
491
+ )
492
+ return None
493
+
494
+ # Keyed on the shared state, not `_entered`: pydantic-ai may hand these
495
+ # calls a per-step copy of this wrapper rather than the entered instance.
496
+ async def get_tools(self, ctx):
497
+ if self.server in self.state.unavailable:
498
+ return {}
499
+ try:
500
+ return await self.wrapped.get_tools(ctx)
501
+ except Exception as error:
502
+ self.state.connect_failed(self.server, error)
503
+ return {}
504
+
505
+ async def get_instructions(self, ctx):
506
+ if self.server in self.state.unavailable:
507
+ return None
508
+ return await super().get_instructions(ctx)
509
+
510
+ return IsolatedServer
511
+
512
+
513
+ @functools.cache
514
+ def _live() -> type:
515
+ """The enabled servers as of each model request, not as of the turn's start.
516
+
517
+ A run otherwise fixes its toolsets when it starts, so `/mcp enable` during a
518
+ long turn would wait for the next one. Instead each step (a model request,
519
+ or the tool calls answering one) compares the run's entered servers with
520
+ `MCPState.enabled`: a server enabled meanwhile is connected and listed from
521
+ the next request on, one disabled is disconnected, and the rest keep their
522
+ connections. One instance per run (`for_run`), so concurrent runs (a side
523
+ question, a worker) never share entered state. (`visit_and_replace` would
524
+ copy one without that state, but only durable execution calls it.)
525
+
526
+ The `<mcp-servers>` notice reads `MCPState` itself a moment later, in
527
+ `before_model_request`, so a change landing between the two can list a
528
+ server one request before (or after) its tools. The next request agrees.
529
+ """
530
+ from dataclasses import dataclass, field
531
+
532
+ from pydantic_ai.toolsets import CombinedToolset
533
+
534
+ @dataclass
535
+ class LiveServers(CombinedToolset):
536
+ toolsets: list = field(default_factory=list)
537
+ state: Any = None
538
+ # name -> (the enabled toolset, the entered wrapper around it)
539
+ _entered: dict = field(default_factory=dict, init=False, compare=False, repr=False)
540
+
541
+ async def for_run(self, ctx):
542
+ return LiveServers(state=self.state)
543
+
544
+ async def for_run_step(self, ctx):
545
+ await self._sync()
546
+ return self
547
+
548
+ async def __aenter__(self):
549
+ # The run's exit stack only exits what entered successfully, so a
550
+ # cancelled first sync must close the servers it already opened.
551
+ try:
552
+ await self._sync()
553
+ except BaseException:
554
+ await self.__aexit__(*sys.exc_info())
555
+ raise
556
+ return self
557
+
558
+ async def __aexit__(self, *args: Any) -> bool | None:
559
+ entered, self._entered, self.toolsets = self._entered, {}, []
560
+ # A stack, so one exit that is cancelled or fails still runs the rest.
561
+ async with AsyncExitStack() as stack:
562
+ for _, wrapper in entered.values():
563
+ stack.push_async_exit(wrapper)
564
+ return None
565
+
566
+ async def _sync(self) -> None:
567
+ wanted = dict(self.state.enabled)
568
+ for name, (toolset, wrapper) in list(self._entered.items()):
569
+ # Identity, not name: undefer() swaps a server's toolset in place.
570
+ if wanted.get(name) is not toolset:
571
+ del self._entered[name]
572
+ await wrapper.__aexit__(None, None, None)
573
+ isolated = _isolated()
574
+ for name, toolset in wanted.items():
575
+ if name not in self._entered:
576
+ wrapper = isolated(toolset, server=name, state=self.state)
577
+ # Never raises for a failed connection: IsolatedServer
578
+ # records it and lists no tools instead.
579
+ await wrapper.__aenter__()
580
+ self._entered[name] = (toolset, wrapper)
581
+ self.toolsets = [wrapper for _, wrapper in self._entered.values()]
582
+
583
+ return LiveServers
584
+
585
+
586
+ class MCPState:
587
+ """Never persisted. Disabled servers have no toolsets, connections, or prompt cost."""
588
+
589
+ def __init__(self) -> None:
590
+ self.enabled: dict[str, Any] = {}
591
+ # Captured at enable, like the toolset, so a later config edit changes
592
+ # neither until the server is disabled and enabled again.
593
+ self.descriptions: dict[str, str] = {}
594
+ # Enabled servers whose latest connection attempt failed, with a message
595
+ # safe to show (fixed text and an exception type, nothing the server
596
+ # sent). Cleared by the next successful connection, retried every turn.
597
+ self.unavailable: dict[str, str] = {}
598
+ # Set by the runtime to surface a failure and save its frames.
599
+ self.on_connect_failure: Callable[[str, BaseException], None] = lambda name, error: None
600
+
601
+ def connect_failed(self, name: str, error: BaseException) -> None:
602
+ self.unavailable[name] = (
603
+ f"MCP server '{name}' failed to connect ({type(error).__name__}); "
604
+ "continuing without its tools this turn."
605
+ )
606
+ self.on_connect_failure(name, error)
607
+
608
+ async def enable(self, name: str, *, interactive: bool = True) -> None:
609
+ if name in self.enabled:
610
+ return
611
+ servers = configured_servers()
612
+ if name not in servers:
613
+ raise ValueError(f"Unknown MCP server '{name}'. Use /mcp list.")
614
+ # Read before building, so a bad entry fails here, not after enabling.
615
+ description = _config(name, servers[name]).description
616
+ toolset = build_toolset(name, servers[name], interactive=interactive)
617
+ # Only OAuth needs an enable-time connection. Entering the MCP toolset
618
+ # initializes the server and completes native auth without a model call.
619
+ # Publish it only after successful login AND connection cleanup, retaining
620
+ # the same OAuth object (and its loaded tokens) for subsequent turns.
621
+ if getattr(mcp_transport(toolset), "auth", None) is not None:
622
+ try:
623
+ async with toolset:
624
+ pass
625
+ except Exception as error:
626
+ # FastMCP wraps connection failures; callers need the sign-in
627
+ # verdict itself to tell "run /mcp enable" from a real failure.
628
+ from pcode.mcp_oauth import SignInRequired
629
+
630
+ if (sign_in := find_cause(error, SignInRequired)) is not None:
631
+ raise sign_in from error
632
+ raise
633
+ self.enabled[name] = toolset
634
+ if description:
635
+ self.descriptions[name] = description
636
+
637
+ async def forget(self, name: str) -> None:
638
+ """Drop stored OAuth credentials for a server, and its toolset if enabled."""
639
+ self.enabled.pop(name, None)
640
+ self.descriptions.pop(name, None)
641
+ self.unavailable.pop(name, None)
642
+ servers = configured_servers()
643
+ if name not in servers:
644
+ raise ValueError(f"Unknown MCP server '{name}'. Use /mcp list.")
645
+ config = _config(name, servers[name])
646
+ if config.auth != "oauth":
647
+ raise ValueError(f"MCP server '{name}' does not use OAuth.")
648
+ from fastmcp.client.auth.oauth import TokenStorageAdapter
649
+
650
+ from pcode.mcp_oauth import CredentialStore
651
+
652
+ await TokenStorageAdapter(CredentialStore(), config.url.rstrip("/")).clear()
653
+
654
+ def disable(self, name: str) -> None:
655
+ # No config read: disabling must work even if the config was removed or broken.
656
+ if name not in self.enabled:
657
+ raise ValueError(f"MCP server '{name}' is not enabled.")
658
+ del self.enabled[name]
659
+ self.descriptions.pop(name, None)
660
+ self.unavailable.pop(name, None)
661
+
662
+ def live(self):
663
+ """The enabled servers as one toolset that follows `enabled` while a run is
664
+ in progress, each isolated so one that cannot connect costs only its tools."""
665
+ return _live()(state=self)
666
+
667
+ def servers(self) -> dict[str, str | None]:
668
+ """Enabled server names, sorted, with the descriptions configured for them."""
669
+ return {name: self.descriptions.get(name) for name in sorted(self.enabled)}
670
+
671
+ def undefer(self) -> list[str]:
672
+ """Send every enabled server's schemas up front, for the rest of the session.
673
+
674
+ The recovery for a provider that rejects withheld schemas: the tools stay
675
+ available (at their full prompt cost) instead of the session failing every
676
+ request. Returns the servers that were still deferring.
677
+ """
678
+ undeferred = []
679
+ for name, toolset in list(self.enabled.items()):
680
+ if (direct := _without_deferred_loading(toolset)) is not None:
681
+ self.enabled[name] = direct
682
+ undeferred.append(name)
683
+ return undeferred