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.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
|