zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,2427 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) tools loader.
|
|
2
|
+
|
|
3
|
+
This module provides async functions to load and manage MCP servers using
|
|
4
|
+
`langchain-mcp-adapters`, supporting Claude Desktop style JSON configs.
|
|
5
|
+
It also supports automatic discovery of `.mcp.json` files from user-level
|
|
6
|
+
and project-level locations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import copy
|
|
13
|
+
import fnmatch
|
|
14
|
+
import functools
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
from contextlib import AsyncExitStack
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast, overload
|
|
23
|
+
|
|
24
|
+
from deepagents_code.mcp_config import resolve_mcp_server_env
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
28
|
+
|
|
29
|
+
from langchain_core.tools import BaseTool
|
|
30
|
+
from langchain_mcp_adapters.client import Connection
|
|
31
|
+
from mcp import ClientSession
|
|
32
|
+
|
|
33
|
+
from deepagents_code.model_config import McpServerTrustLists
|
|
34
|
+
from deepagents_code.project_utils import ProjectContext
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
_T = TypeVar("_T")
|
|
39
|
+
|
|
40
|
+
# Maintainer note: `deepagents-talon` imports `MCPConfigError`,
|
|
41
|
+
# `MCPServerInfo`, and `get_mcp_tools` from this module, and its tests construct
|
|
42
|
+
# `MCPToolInfo`. Keep those symbols' names, signatures, and return/dataclass
|
|
43
|
+
# shapes stable unless `deepagents-talon` is migrated in the same change.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class MCPToolInfo:
|
|
48
|
+
"""Metadata for a single MCP tool."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
"""Tool name (may include server name prefix)."""
|
|
52
|
+
|
|
53
|
+
description: str
|
|
54
|
+
"""Human-readable description of what the tool does."""
|
|
55
|
+
|
|
56
|
+
input_schema: dict[str, Any] | None = None
|
|
57
|
+
"""Raw MCP `inputSchema` dict (JSON Schema), or `None` when unavailable.
|
|
58
|
+
|
|
59
|
+
Supplied directly from `mcp_tool.inputSchema` at tool-load time. The viewer
|
|
60
|
+
reads `properties` and `required` from this dict for parameter display;
|
|
61
|
+
`None` is rendered as "no parameters".
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
MCPServerStatus = Literal[
|
|
66
|
+
"ok",
|
|
67
|
+
"unauthenticated",
|
|
68
|
+
"awaiting_reconnect",
|
|
69
|
+
"error",
|
|
70
|
+
"disabled",
|
|
71
|
+
]
|
|
72
|
+
"""Load states a configured MCP server can end up in.
|
|
73
|
+
|
|
74
|
+
`ok` means the server loaded successfully and has an authoritative tool list.
|
|
75
|
+
|
|
76
|
+
`unauthenticated` means the server requires OAuth login before tools can load.
|
|
77
|
+
|
|
78
|
+
`error` means the server failed to load after a connection or configuration
|
|
79
|
+
failure.
|
|
80
|
+
|
|
81
|
+
`disabled` is set when the user has turned the server off via the TUI
|
|
82
|
+
(`/mcp` -> F2). No connection is attempted and no tools are loaded, but
|
|
83
|
+
the entry is still surfaced in the viewer so the user can re-enable it.
|
|
84
|
+
|
|
85
|
+
`awaiting_reconnect` is a transient UI-only state used after OAuth login
|
|
86
|
+
has succeeded but before the LangGraph server has restarted and loaded
|
|
87
|
+
the newly available MCP tools.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class MCPServerInfo:
|
|
93
|
+
"""Metadata for a configured MCP server and its tools."""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
"""Server name from the MCP configuration."""
|
|
97
|
+
|
|
98
|
+
transport: str
|
|
99
|
+
"""Transport identifier — `stdio`, `sse`, `http`, the synthetic
|
|
100
|
+
`config` value used for entries surfacing a bad config file, or
|
|
101
|
+
`unknown` for a disabled server whose original config could not be
|
|
102
|
+
classified."""
|
|
103
|
+
|
|
104
|
+
tools: tuple[MCPToolInfo, ...] = ()
|
|
105
|
+
"""Tools exposed by this server (empty when `status != "ok"`)."""
|
|
106
|
+
|
|
107
|
+
status: MCPServerStatus = "ok"
|
|
108
|
+
"""Load status.
|
|
109
|
+
|
|
110
|
+
One of `ok`, `unauthenticated`, `awaiting_reconnect`, `error`, or
|
|
111
|
+
`disabled`.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
error: str | None = None
|
|
115
|
+
"""Human-readable reason when `status != "ok"`."""
|
|
116
|
+
|
|
117
|
+
pending_reconnect: bool = False
|
|
118
|
+
"""`True` for a disabled entry that was just re-enabled in the TUI and is
|
|
119
|
+
awaiting a reconnect to load its tools.
|
|
120
|
+
|
|
121
|
+
Lets `/tools` (`tool_catalog.split_mcp_server_info`) preserve the reconnect
|
|
122
|
+
guidance held in `error` instead of collapsing it to the generic "disabled
|
|
123
|
+
by user" label — an explicit flag rather than a fragile match on the
|
|
124
|
+
guidance text. Only meaningful while `status == "disabled"`.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
"""Enforce the status/error/tools consistency invariant.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If any of: `status='ok'` with a non-`None` error;
|
|
132
|
+
non-`ok` status without an error message; non-`ok` status
|
|
133
|
+
carrying tools; or `pending_reconnect` set without
|
|
134
|
+
`status='disabled'`.
|
|
135
|
+
"""
|
|
136
|
+
if self.status == "ok":
|
|
137
|
+
if self.error is not None:
|
|
138
|
+
msg = (
|
|
139
|
+
f"MCPServerInfo {self.name!r}: status='ok' cannot carry "
|
|
140
|
+
f"an error (got {self.error!r})"
|
|
141
|
+
)
|
|
142
|
+
raise ValueError(msg)
|
|
143
|
+
else:
|
|
144
|
+
if self.error is None:
|
|
145
|
+
msg = (
|
|
146
|
+
f"MCPServerInfo {self.name!r}: status={self.status!r} "
|
|
147
|
+
"requires an error message"
|
|
148
|
+
)
|
|
149
|
+
raise ValueError(msg)
|
|
150
|
+
if self.tools:
|
|
151
|
+
msg = (
|
|
152
|
+
f"MCPServerInfo {self.name!r}: status={self.status!r} "
|
|
153
|
+
"cannot carry tools"
|
|
154
|
+
)
|
|
155
|
+
raise ValueError(msg)
|
|
156
|
+
if self.pending_reconnect and self.status != "disabled":
|
|
157
|
+
msg = (
|
|
158
|
+
f"MCPServerInfo {self.name!r}: pending_reconnect requires "
|
|
159
|
+
f"status='disabled' (got {self.status!r})"
|
|
160
|
+
)
|
|
161
|
+
raise ValueError(msg)
|
|
162
|
+
|
|
163
|
+
def needs_attention(self) -> bool:
|
|
164
|
+
"""Return whether this server is blocked on user login."""
|
|
165
|
+
return self.status == "unauthenticated"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_SUPPORTED_REMOTE_TYPES = {"sse", "http"}
|
|
169
|
+
"""Supported transport types for remote MCP servers (SSE and HTTP)."""
|
|
170
|
+
|
|
171
|
+
_TRANSPORT_ALIASES = {"streamable_http": "http", "streamable-http": "http"}
|
|
172
|
+
"""Aliases that normalize to canonical transport names.
|
|
173
|
+
|
|
174
|
+
The MCP spec and `langchain_mcp_adapters` use `streamable_http` for what the
|
|
175
|
+
app calls `http`. Accept both so users copy-pasting from upstream docs don't
|
|
176
|
+
hit a validation error.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
181
|
+
"""Server names become token-file basenames and must remain path-safe."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class MCPConfigError(ValueError):
|
|
185
|
+
"""An MCP configuration file is malformed or structurally invalid.
|
|
186
|
+
|
|
187
|
+
Subclasses `ValueError` so existing `except ValueError` handlers
|
|
188
|
+
keep working; new code can catch this specifically to render a
|
|
189
|
+
user-actionable message (typically with a file path and hint).
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_transient_session_error(exc: BaseException) -> bool:
|
|
194
|
+
"""Return `True` when `exc` signals the MCP session transport is dead.
|
|
195
|
+
|
|
196
|
+
The anyio import is guarded so an anyio rename or removal surfaces as
|
|
197
|
+
an `ImportError` at module import rather than silent mis-classification
|
|
198
|
+
at runtime. Standard-library socket/pipe/EOF errors are covered as a
|
|
199
|
+
fallback regardless of anyio's presence.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
import anyio
|
|
203
|
+
except ImportError: # pragma: no cover - anyio is a transitive MCP dep
|
|
204
|
+
anyio_excs: tuple[type[BaseException], ...] = ()
|
|
205
|
+
else:
|
|
206
|
+
anyio_excs = (
|
|
207
|
+
anyio.ClosedResourceError,
|
|
208
|
+
anyio.BrokenResourceError,
|
|
209
|
+
anyio.EndOfStream,
|
|
210
|
+
)
|
|
211
|
+
return isinstance(
|
|
212
|
+
exc,
|
|
213
|
+
(
|
|
214
|
+
*anyio_excs,
|
|
215
|
+
BrokenPipeError,
|
|
216
|
+
ConnectionAbortedError,
|
|
217
|
+
ConnectionResetError,
|
|
218
|
+
EOFError,
|
|
219
|
+
asyncio.IncompleteReadError,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class _MCPSessionEntry:
|
|
226
|
+
"""Cached MCP session and its close stack."""
|
|
227
|
+
|
|
228
|
+
session: ClientSession
|
|
229
|
+
exit_stack: AsyncExitStack
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _connection_signature(value: Any) -> Any: # noqa: ANN401
|
|
233
|
+
"""Return a stable comparison signature for MCP connection configs."""
|
|
234
|
+
from mcp.client.auth import OAuthClientProvider
|
|
235
|
+
|
|
236
|
+
if isinstance(value, dict):
|
|
237
|
+
return tuple(
|
|
238
|
+
sorted((key, _connection_signature(item)) for key, item in value.items()),
|
|
239
|
+
)
|
|
240
|
+
if isinstance(value, list | tuple):
|
|
241
|
+
return tuple(_connection_signature(item) for item in value)
|
|
242
|
+
if isinstance(value, Path):
|
|
243
|
+
return str(value)
|
|
244
|
+
if isinstance(value, OAuthClientProvider):
|
|
245
|
+
context = value.context
|
|
246
|
+
storage_path = getattr(getattr(context, "storage", None), "path", None)
|
|
247
|
+
return (
|
|
248
|
+
"oauth",
|
|
249
|
+
_connection_signature(context.server_url),
|
|
250
|
+
_connection_signature(
|
|
251
|
+
context.client_metadata.model_dump(mode="json", exclude_none=True),
|
|
252
|
+
),
|
|
253
|
+
_connection_signature(storage_path),
|
|
254
|
+
_connection_signature(context.timeout),
|
|
255
|
+
_connection_signature(context.client_metadata_url),
|
|
256
|
+
_connection_signature(context.auth_server_url),
|
|
257
|
+
_connection_signature(context.protocol_version),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
model_dump = getattr(value, "model_dump", None)
|
|
261
|
+
if callable(model_dump):
|
|
262
|
+
return _connection_signature(model_dump(mode="json", exclude_none=True))
|
|
263
|
+
return value
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _connections_signature(
|
|
267
|
+
connections: dict[str, Connection],
|
|
268
|
+
) -> tuple[tuple[str, Any], ...]:
|
|
269
|
+
"""Return a stable signature for a full MCP connections mapping."""
|
|
270
|
+
return tuple(
|
|
271
|
+
sorted(
|
|
272
|
+
(name, _connection_signature(connection))
|
|
273
|
+
for name, connection in connections.items()
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class MCPSessionManager:
|
|
279
|
+
"""Lazy, per-server cache of persistent MCP sessions.
|
|
280
|
+
|
|
281
|
+
Discovery always happens through throwaway sessions. Live sessions are
|
|
282
|
+
only created on the first real tool call inside the runtime event loop
|
|
283
|
+
so sessions stay bound to the loop that owns their subprocess/transport
|
|
284
|
+
handles, and so stdio servers are not restarted on every invocation.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
def __init__(self, *, connections: dict[str, Connection] | None = None) -> None:
|
|
288
|
+
"""Initialize the session manager.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
connections: Optional initial server connection configs.
|
|
292
|
+
"""
|
|
293
|
+
self._connections: dict[str, Connection] = dict(connections or {})
|
|
294
|
+
self._entries: dict[str, _MCPSessionEntry] = {}
|
|
295
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
296
|
+
self._closed = False
|
|
297
|
+
|
|
298
|
+
def configure(self, connections: dict[str, Connection]) -> None:
|
|
299
|
+
"""Set or validate the connection configs used by this manager.
|
|
300
|
+
|
|
301
|
+
When no sessions exist yet, `connections` overwrites the stored
|
|
302
|
+
configs unconditionally. Once any session has been created, the
|
|
303
|
+
new `connections` must produce the same signature as the stored
|
|
304
|
+
ones — otherwise this raises to prevent rebinding live sessions
|
|
305
|
+
to different transports or auth providers.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
connections: Connection configs keyed by server name.
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
RuntimeError: If the manager is closed or reconfigured
|
|
312
|
+
incompatibly after sessions already exist.
|
|
313
|
+
"""
|
|
314
|
+
if self._closed:
|
|
315
|
+
msg = "Cannot configure a closed MCP session manager"
|
|
316
|
+
raise RuntimeError(msg)
|
|
317
|
+
|
|
318
|
+
if not self._entries:
|
|
319
|
+
self._connections = dict(connections)
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
if _connections_signature(self._connections) != _connections_signature(
|
|
323
|
+
connections,
|
|
324
|
+
):
|
|
325
|
+
msg = "Cannot reconfigure MCP session manager after sessions are active"
|
|
326
|
+
raise RuntimeError(msg)
|
|
327
|
+
self._connections = dict(connections)
|
|
328
|
+
|
|
329
|
+
async def get_session(self, server_name: str) -> ClientSession:
|
|
330
|
+
"""Return a cached session for `server_name`, creating it lazily."""
|
|
331
|
+
entry = self._entries.get(server_name)
|
|
332
|
+
if entry is not None:
|
|
333
|
+
return entry.session
|
|
334
|
+
|
|
335
|
+
lock = self._get_lock(server_name)
|
|
336
|
+
async with lock:
|
|
337
|
+
entry = self._entries.get(server_name)
|
|
338
|
+
if entry is not None:
|
|
339
|
+
return entry.session
|
|
340
|
+
|
|
341
|
+
entry = await self._create_entry(server_name)
|
|
342
|
+
self._entries[server_name] = entry
|
|
343
|
+
return entry.session
|
|
344
|
+
|
|
345
|
+
async def invalidate(
|
|
346
|
+
self,
|
|
347
|
+
server_name: str,
|
|
348
|
+
*,
|
|
349
|
+
expected_session: ClientSession | None = None,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Evict and close a cached session if it still matches `expected_session`.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
server_name: MCP server name.
|
|
355
|
+
expected_session: Optional identity check for race-safe eviction.
|
|
356
|
+
"""
|
|
357
|
+
lock = self._get_lock(server_name)
|
|
358
|
+
async with lock:
|
|
359
|
+
entry = self._entries.get(server_name)
|
|
360
|
+
if entry is None:
|
|
361
|
+
return
|
|
362
|
+
if expected_session is not None and entry.session is not expected_session:
|
|
363
|
+
return
|
|
364
|
+
self._entries.pop(server_name, None)
|
|
365
|
+
exit_stack = entry.exit_stack
|
|
366
|
+
|
|
367
|
+
await exit_stack.aclose()
|
|
368
|
+
|
|
369
|
+
async def cleanup(self) -> None:
|
|
370
|
+
"""Close all cached sessions concurrently and reject future creation.
|
|
371
|
+
|
|
372
|
+
Each server's `exit_stack.aclose()` runs with a 5 second timeout so
|
|
373
|
+
one slow stdio server cannot stall shutdown. Per-server failures
|
|
374
|
+
are logged — teardown is best-effort — but `CancelledError` is
|
|
375
|
+
re-raised so the enclosing `asyncio.gather` still cancels peers.
|
|
376
|
+
"""
|
|
377
|
+
if self._closed and not self._entries:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
self._closed = True
|
|
381
|
+
names = list(self._entries)
|
|
382
|
+
|
|
383
|
+
async def _close(server_name: str) -> None:
|
|
384
|
+
try:
|
|
385
|
+
await asyncio.wait_for(self.invalidate(server_name), timeout=5.0)
|
|
386
|
+
except TimeoutError:
|
|
387
|
+
logger.warning(
|
|
388
|
+
"MCP session cleanup for %r timed out after 5s",
|
|
389
|
+
server_name,
|
|
390
|
+
)
|
|
391
|
+
except (KeyboardInterrupt, SystemExit, asyncio.CancelledError):
|
|
392
|
+
raise
|
|
393
|
+
except Exception:
|
|
394
|
+
logger.warning(
|
|
395
|
+
"MCP session cleanup for %r failed",
|
|
396
|
+
server_name,
|
|
397
|
+
exc_info=True,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
await asyncio.gather(*[_close(name) for name in names])
|
|
401
|
+
|
|
402
|
+
def _get_lock(self, server_name: str) -> asyncio.Lock:
|
|
403
|
+
"""Return the per-server creation/eviction lock."""
|
|
404
|
+
lock = self._locks.get(server_name)
|
|
405
|
+
if lock is None:
|
|
406
|
+
lock = asyncio.Lock()
|
|
407
|
+
self._locks[server_name] = lock
|
|
408
|
+
return lock
|
|
409
|
+
|
|
410
|
+
async def _create_entry(self, server_name: str) -> _MCPSessionEntry:
|
|
411
|
+
"""Create and initialize a new cached session entry.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
server_name: MCP server name.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
A cached session entry containing the live session and close stack.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
RuntimeError: If the manager has already been cleaned up.
|
|
421
|
+
ValueError: If `server_name` is not configured in the manager.
|
|
422
|
+
"""
|
|
423
|
+
if self._closed:
|
|
424
|
+
msg = "Cannot create an MCP session after cleanup"
|
|
425
|
+
raise RuntimeError(msg)
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
connection = self._connections[server_name]
|
|
429
|
+
except KeyError as exc:
|
|
430
|
+
msg = (
|
|
431
|
+
f"Couldn't find an MCP server named '{server_name}', "
|
|
432
|
+
f"expected one of {sorted(self._connections)}"
|
|
433
|
+
)
|
|
434
|
+
raise ValueError(msg) from exc
|
|
435
|
+
|
|
436
|
+
from langchain_mcp_adapters.sessions import create_session
|
|
437
|
+
|
|
438
|
+
exit_stack = AsyncExitStack()
|
|
439
|
+
try:
|
|
440
|
+
session = await exit_stack.enter_async_context(create_session(connection))
|
|
441
|
+
await session.initialize()
|
|
442
|
+
except Exception:
|
|
443
|
+
await exit_stack.aclose()
|
|
444
|
+
raise
|
|
445
|
+
|
|
446
|
+
return _MCPSessionEntry(session=session, exit_stack=exit_stack)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _resolve_server_type(server_config: Mapping[str, Any]) -> str:
|
|
450
|
+
"""Determine the transport type for a server config.
|
|
451
|
+
|
|
452
|
+
Accepts `type` or `transport` interchangeably. When neither is set, a
|
|
453
|
+
`url` field implies a remote server (defaulting to `http`) and the
|
|
454
|
+
absence of `url` implies stdio. This matches Claude Code's `.mcp.json`
|
|
455
|
+
convention where remote entries are commonly written as `{"url": "..."}`
|
|
456
|
+
alone.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
server_config: Server configuration dictionary.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
Transport type string (`stdio`, `sse`, or `http`).
|
|
463
|
+
"""
|
|
464
|
+
transport = server_config.get("type") or server_config.get("transport")
|
|
465
|
+
if transport is not None:
|
|
466
|
+
return _TRANSPORT_ALIASES.get(transport, transport)
|
|
467
|
+
if "url" in server_config:
|
|
468
|
+
return "http"
|
|
469
|
+
return "stdio"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _validate_server_config(server_name: str, server_config: dict[str, Any]) -> None:
|
|
473
|
+
"""Validate a single server configuration.
|
|
474
|
+
|
|
475
|
+
Performs only shape checks — `${VAR}` config interpolation is deferred
|
|
476
|
+
to activation time so one unset env var only fails its own server
|
|
477
|
+
rather than hiding every other MCP entry in the same file.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
server_name: Name of the server.
|
|
481
|
+
server_config: Server configuration dictionary.
|
|
482
|
+
|
|
483
|
+
Raises:
|
|
484
|
+
TypeError: If config fields have wrong types.
|
|
485
|
+
ValueError: If required fields are missing or server type is unsupported.
|
|
486
|
+
"""
|
|
487
|
+
if not _SERVER_NAME_RE.fullmatch(server_name):
|
|
488
|
+
error_msg = (
|
|
489
|
+
f"Invalid server name {server_name!r}: server names must contain "
|
|
490
|
+
"only alphanumerics, hyphens, and underscores."
|
|
491
|
+
)
|
|
492
|
+
raise ValueError(error_msg)
|
|
493
|
+
|
|
494
|
+
if not isinstance(server_config, dict):
|
|
495
|
+
error_msg = f"Server '{server_name}' config must be a dictionary"
|
|
496
|
+
raise TypeError(error_msg)
|
|
497
|
+
|
|
498
|
+
server_type = _resolve_server_type(server_config)
|
|
499
|
+
|
|
500
|
+
if server_type in _SUPPORTED_REMOTE_TYPES:
|
|
501
|
+
if "url" not in server_config:
|
|
502
|
+
error_msg = (
|
|
503
|
+
f"Server '{server_name}' with type '{server_type}' "
|
|
504
|
+
"missing required 'url' field"
|
|
505
|
+
)
|
|
506
|
+
raise ValueError(error_msg)
|
|
507
|
+
|
|
508
|
+
if "command" in server_config:
|
|
509
|
+
error_msg = (
|
|
510
|
+
f"Server '{server_name}' has type '{server_type}' (remote) "
|
|
511
|
+
"but also declares a 'command' field. Remove 'command' or "
|
|
512
|
+
'set `"type": "stdio"`.'
|
|
513
|
+
)
|
|
514
|
+
raise ValueError(error_msg)
|
|
515
|
+
|
|
516
|
+
headers = server_config.get("headers")
|
|
517
|
+
if headers is not None and not isinstance(headers, dict):
|
|
518
|
+
error_msg = f"Server '{server_name}' 'headers' must be a dictionary"
|
|
519
|
+
raise TypeError(error_msg)
|
|
520
|
+
|
|
521
|
+
if isinstance(headers, dict):
|
|
522
|
+
for name, value in headers.items():
|
|
523
|
+
if not isinstance(value, str):
|
|
524
|
+
error_msg = (
|
|
525
|
+
f"Server '{server_name}' header {name!r} must be "
|
|
526
|
+
f"a string, got {type(value).__name__}"
|
|
527
|
+
)
|
|
528
|
+
raise TypeError(error_msg)
|
|
529
|
+
elif server_type == "stdio":
|
|
530
|
+
if "command" not in server_config:
|
|
531
|
+
error_msg = f"Server '{server_name}' missing required 'command' field"
|
|
532
|
+
raise ValueError(error_msg)
|
|
533
|
+
|
|
534
|
+
if "url" in server_config:
|
|
535
|
+
error_msg = (
|
|
536
|
+
f"Server '{server_name}' has type 'stdio' but also declares "
|
|
537
|
+
"a 'url' field. Remove 'url' or set "
|
|
538
|
+
'`"type": "http"` (or `"sse"`) for a remote server.'
|
|
539
|
+
)
|
|
540
|
+
raise ValueError(error_msg)
|
|
541
|
+
|
|
542
|
+
if "args" in server_config and not isinstance(server_config["args"], list):
|
|
543
|
+
error_msg = f"Server '{server_name}' 'args' must be a list"
|
|
544
|
+
raise TypeError(error_msg)
|
|
545
|
+
|
|
546
|
+
if "env" in server_config and not isinstance(server_config["env"], dict):
|
|
547
|
+
error_msg = f"Server '{server_name}' 'env' must be a dictionary"
|
|
548
|
+
raise TypeError(error_msg)
|
|
549
|
+
else:
|
|
550
|
+
error_msg = (
|
|
551
|
+
f"Server '{server_name}' has unsupported transport type '{server_type}'. "
|
|
552
|
+
"Supported types: stdio, sse, http"
|
|
553
|
+
)
|
|
554
|
+
raise ValueError(error_msg)
|
|
555
|
+
|
|
556
|
+
auth = server_config.get("auth")
|
|
557
|
+
if auth is not None:
|
|
558
|
+
if auth != "oauth":
|
|
559
|
+
msg = (
|
|
560
|
+
f"Server '{server_name}' has unsupported auth value "
|
|
561
|
+
f"{auth!r}. Only 'oauth' is supported."
|
|
562
|
+
)
|
|
563
|
+
raise ValueError(msg)
|
|
564
|
+
if server_type == "stdio":
|
|
565
|
+
msg = (
|
|
566
|
+
f"Server '{server_name}' uses stdio transport; "
|
|
567
|
+
"'auth: oauth' is only valid for http/sse transports."
|
|
568
|
+
)
|
|
569
|
+
raise ValueError(msg)
|
|
570
|
+
header_names = {name.lower() for name in (server_config.get("headers") or {})}
|
|
571
|
+
if "authorization" in header_names:
|
|
572
|
+
msg = (
|
|
573
|
+
f"Server '{server_name}' cannot combine 'auth: oauth' "
|
|
574
|
+
"with an 'Authorization' header."
|
|
575
|
+
)
|
|
576
|
+
raise ValueError(msg)
|
|
577
|
+
|
|
578
|
+
_validate_tool_filter_fields(server_name, server_config)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _validate_tool_filter_fields(
|
|
582
|
+
server_name: str,
|
|
583
|
+
server_config: dict[str, Any],
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Validate optional `allowedTools` / `disabledTools` fields.
|
|
586
|
+
|
|
587
|
+
Both fields, when present, must be non-empty lists of strings. Setting
|
|
588
|
+
both on the same server is rejected to keep the filter semantics
|
|
589
|
+
unambiguous. An empty list is rejected because it would silently strip
|
|
590
|
+
every tool from the server (`allowedTools`) or be a no-op
|
|
591
|
+
(`disabledTools`) — both are almost certainly user errors; omit the field
|
|
592
|
+
instead.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
server_name: Name of the server (for error messages).
|
|
596
|
+
server_config: Server configuration dictionary.
|
|
597
|
+
|
|
598
|
+
Raises:
|
|
599
|
+
TypeError: If a field is not a list of strings.
|
|
600
|
+
ValueError: If both fields are set, or either field is empty.
|
|
601
|
+
"""
|
|
602
|
+
has_allowed = "allowedTools" in server_config
|
|
603
|
+
has_disabled = "disabledTools" in server_config
|
|
604
|
+
if has_allowed and has_disabled:
|
|
605
|
+
error_msg = (
|
|
606
|
+
f"Server '{server_name}' cannot set both 'allowedTools' and"
|
|
607
|
+
" 'disabledTools' — pick one."
|
|
608
|
+
)
|
|
609
|
+
raise ValueError(error_msg)
|
|
610
|
+
|
|
611
|
+
for field_name in ("allowedTools", "disabledTools"):
|
|
612
|
+
if field_name not in server_config:
|
|
613
|
+
continue
|
|
614
|
+
value = server_config[field_name]
|
|
615
|
+
if not isinstance(value, list) or not all(
|
|
616
|
+
isinstance(item, str) for item in value
|
|
617
|
+
):
|
|
618
|
+
error_msg = (
|
|
619
|
+
f"Server '{server_name}' '{field_name}' must be a list of strings"
|
|
620
|
+
)
|
|
621
|
+
raise TypeError(error_msg)
|
|
622
|
+
if not value:
|
|
623
|
+
error_msg = (
|
|
624
|
+
f"Server '{server_name}' '{field_name}' must be non-empty;"
|
|
625
|
+
" omit the field to disable filtering."
|
|
626
|
+
)
|
|
627
|
+
raise ValueError(error_msg)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _looks_like_comment(doc: str, lineno: int) -> bool:
|
|
631
|
+
"""Return `True` if the offending line *begins* with `//` or `/*`.
|
|
632
|
+
|
|
633
|
+
Only the failing line is checked, and only its leading characters (after
|
|
634
|
+
stripping indentation). A `url` value such as `"url": "https://..."`
|
|
635
|
+
begins with a quote, not `//`, so a URL scheme inside a quoted string
|
|
636
|
+
never triggers a false comment hint.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
doc: Full source text that failed to parse.
|
|
640
|
+
lineno: 1-based line number of the error; out-of-range values
|
|
641
|
+
return `False`.
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
`True` when the stripped failing line starts with `//` or `/*`.
|
|
645
|
+
"""
|
|
646
|
+
lines = doc.splitlines()
|
|
647
|
+
if lineno < 1 or lineno > len(lines):
|
|
648
|
+
return False
|
|
649
|
+
stripped = lines[lineno - 1].lstrip()
|
|
650
|
+
return stripped.startswith(("//", "/*"))
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _json_error_hint(exc: json.JSONDecodeError) -> str | None:
|
|
654
|
+
"""Return an actionable hint for a common JSON mistake, or `None`.
|
|
655
|
+
|
|
656
|
+
Checks are ordered most-specific-first (trailing comma, then comment,
|
|
657
|
+
then generic decoder-message keywords) so a more precise hint wins when
|
|
658
|
+
several could apply.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
exc: The decode error to classify.
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
A hint string for a recognized mistake, or `None` when no specific
|
|
665
|
+
guidance applies.
|
|
666
|
+
"""
|
|
667
|
+
msg = exc.msg.lower()
|
|
668
|
+
if "trailing comma" in msg:
|
|
669
|
+
return (
|
|
670
|
+
"Hint: JSON does not allow trailing commas. Remove the comma "
|
|
671
|
+
"before the closing '}' or ']'."
|
|
672
|
+
)
|
|
673
|
+
if _looks_like_comment(exc.doc, exc.lineno):
|
|
674
|
+
return "Hint: JSON does not allow comments (// or /* */). Remove them."
|
|
675
|
+
if "expecting property name" in msg:
|
|
676
|
+
return (
|
|
677
|
+
"Hint: check for trailing commas, a missing key, or an unquoted "
|
|
678
|
+
"property name near this position."
|
|
679
|
+
)
|
|
680
|
+
if "expecting value" in msg:
|
|
681
|
+
return (
|
|
682
|
+
"Hint: check for a missing value, an extra comma, or unquoted text "
|
|
683
|
+
"near this position."
|
|
684
|
+
)
|
|
685
|
+
if "delimiter" in msg:
|
|
686
|
+
return (
|
|
687
|
+
"Hint: check for a missing comma, ':', or closing bracket near "
|
|
688
|
+
"this position."
|
|
689
|
+
)
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _trailing_comma_pos(doc: str, pos: int) -> int | None:
|
|
694
|
+
"""Return the comma position for decoder errors at a trailing comma."""
|
|
695
|
+
if pos < 0 or pos >= len(doc) or doc[pos] not in "}]":
|
|
696
|
+
return None
|
|
697
|
+
idx = pos - 1
|
|
698
|
+
while idx >= 0 and doc[idx].isspace():
|
|
699
|
+
idx -= 1
|
|
700
|
+
if idx >= 0 and doc[idx] == ",":
|
|
701
|
+
return idx
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _json_error_snippet(
|
|
706
|
+
doc: str, lineno: int, colno: int, *, pos: int | None = None
|
|
707
|
+
) -> str | None:
|
|
708
|
+
"""Build a caret snippet pointing at a JSON error location.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
doc: Full source text that failed to parse.
|
|
712
|
+
lineno: 1-based line number of the error.
|
|
713
|
+
colno: 1-based column number of the error.
|
|
714
|
+
pos: 0-based absolute error offset, if available.
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
A two-line `<source line>` + caret string, or `None` when the line
|
|
718
|
+
is out of range or blank.
|
|
719
|
+
"""
|
|
720
|
+
if pos is not None:
|
|
721
|
+
trailing_pos = _trailing_comma_pos(doc, pos)
|
|
722
|
+
if trailing_pos is not None:
|
|
723
|
+
lineno = doc.count("\n", 0, trailing_pos) + 1
|
|
724
|
+
line_start = doc.rfind("\n", 0, trailing_pos) + 1
|
|
725
|
+
colno = trailing_pos - line_start + 1
|
|
726
|
+
lines = doc.splitlines()
|
|
727
|
+
if lineno < 1 or lineno > len(lines):
|
|
728
|
+
return None
|
|
729
|
+
source = lines[lineno - 1].rstrip()
|
|
730
|
+
if not source:
|
|
731
|
+
return None
|
|
732
|
+
caret_col = max(0, min(colno - 1, len(source)))
|
|
733
|
+
return f" {source}\n {' ' * caret_col}^"
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _load_mcp_config_json(config_path: str) -> dict[str, Any]:
|
|
737
|
+
"""Load MCP configuration JSON with parser diagnostics.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
config_path: Path to the MCP JSON configuration file.
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
Parsed configuration dictionary.
|
|
744
|
+
|
|
745
|
+
Raises:
|
|
746
|
+
FileNotFoundError: If config file doesn't exist.
|
|
747
|
+
json.JSONDecodeError: If config file contains invalid JSON.
|
|
748
|
+
"""
|
|
749
|
+
path = Path(config_path)
|
|
750
|
+
|
|
751
|
+
if not path.exists():
|
|
752
|
+
error_msg = f"MCP config file not found: {config_path}"
|
|
753
|
+
raise FileNotFoundError(error_msg)
|
|
754
|
+
|
|
755
|
+
try:
|
|
756
|
+
with path.open(encoding="utf-8") as file_obj:
|
|
757
|
+
return json.load(file_obj)
|
|
758
|
+
except json.JSONDecodeError as exc:
|
|
759
|
+
# Build a layered message: core reason, an actionable hint for common
|
|
760
|
+
# mistakes, then a caret snippet last so the auto-appended
|
|
761
|
+
# "line X column Y" suffix reads as the location of the caret.
|
|
762
|
+
parts = [f"Invalid JSON in MCP config file: {exc.msg}"]
|
|
763
|
+
hint = _json_error_hint(exc)
|
|
764
|
+
if hint is not None:
|
|
765
|
+
parts.append(hint)
|
|
766
|
+
snippet = _json_error_snippet(exc.doc, exc.lineno, exc.colno, pos=exc.pos)
|
|
767
|
+
if snippet is not None:
|
|
768
|
+
parts.append(snippet)
|
|
769
|
+
error_msg = "\n".join(parts)
|
|
770
|
+
raise json.JSONDecodeError(error_msg, exc.doc, exc.pos) from exc
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _validate_mcp_config_top_level(config: dict[str, Any]) -> None:
|
|
774
|
+
"""Validate top-level MCP configuration fields.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
config: Parsed MCP config dictionary.
|
|
778
|
+
|
|
779
|
+
Raises:
|
|
780
|
+
TypeError: If top-level fields have wrong types.
|
|
781
|
+
ValueError: If required top-level fields are missing.
|
|
782
|
+
"""
|
|
783
|
+
if "mcpServers" not in config:
|
|
784
|
+
error_msg = (
|
|
785
|
+
"MCP config must contain 'mcpServers' field. "
|
|
786
|
+
'Expected format: {"mcpServers": {"server-name": {...}}}'
|
|
787
|
+
)
|
|
788
|
+
raise ValueError(error_msg)
|
|
789
|
+
|
|
790
|
+
if not isinstance(config["mcpServers"], dict):
|
|
791
|
+
error_msg = "'mcpServers' field must be a dictionary"
|
|
792
|
+
raise TypeError(error_msg)
|
|
793
|
+
|
|
794
|
+
if not config["mcpServers"]:
|
|
795
|
+
error_msg = "'mcpServers' field is empty - no servers configured"
|
|
796
|
+
raise ValueError(error_msg)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _validate_mcp_config_servers(config: dict[str, Any]) -> None:
|
|
800
|
+
"""Validate every server in an MCP configuration.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
config: Parsed MCP config dictionary.
|
|
804
|
+
"""
|
|
805
|
+
for server_name, server_config in config["mcpServers"].items():
|
|
806
|
+
_validate_server_config(server_name, server_config)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _load_mcp_config_top_level(config_path: Path) -> dict[str, Any]:
|
|
810
|
+
"""Load an MCP config file and validate only its top-level shape.
|
|
811
|
+
|
|
812
|
+
Args:
|
|
813
|
+
config_path: Config path to load.
|
|
814
|
+
|
|
815
|
+
Returns:
|
|
816
|
+
Parsed configuration dictionary with a valid `mcpServers` mapping.
|
|
817
|
+
"""
|
|
818
|
+
config = _load_mcp_config_json(str(config_path))
|
|
819
|
+
_validate_mcp_config_top_level(config)
|
|
820
|
+
return config
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def load_mcp_config(config_path: str) -> dict[str, Any]:
|
|
824
|
+
"""Load and validate MCP configuration from a JSON file.
|
|
825
|
+
|
|
826
|
+
Supports multiple server types:
|
|
827
|
+
|
|
828
|
+
- stdio: Process-based servers with `command`, `args`, `env` fields (default)
|
|
829
|
+
- sse: Server-Sent Events servers with `type: "sse"`, `url`, and optional `headers`
|
|
830
|
+
- http: HTTP-based servers with `type: "http"`, `url`, and optional `headers`
|
|
831
|
+
|
|
832
|
+
Any server type may also set an optional tool filter:
|
|
833
|
+
|
|
834
|
+
- `allowedTools`: list of tool names or patterns to keep (all others dropped)
|
|
835
|
+
- `disabledTools`: list of tool names or patterns to drop (all others kept)
|
|
836
|
+
|
|
837
|
+
Entries are either literal tool names or `fnmatch`-style glob patterns
|
|
838
|
+
(entries containing `*`, `?`, or `[`). Each entry is matched against both
|
|
839
|
+
the bare MCP tool name and the server-prefixed form
|
|
840
|
+
(`f"{server_name}_{tool}"`), so either `read_*` or `fs_read_*` works.
|
|
841
|
+
Setting both fields on a single server is an error.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
config_path: Path to the MCP JSON configuration file.
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
Parsed configuration dictionary.
|
|
848
|
+
|
|
849
|
+
Raises:
|
|
850
|
+
FileNotFoundError: If config file doesn't exist.
|
|
851
|
+
json.JSONDecodeError: If config file contains invalid JSON.
|
|
852
|
+
TypeError: If config fields have wrong types.
|
|
853
|
+
ValueError: If config is missing required fields.
|
|
854
|
+
""" # noqa: DOC502 - raised indirectly by `_load_mcp_config_json` / `_validate_server_config` (which does shape-only checks; `${VAR}` config interpolation is deferred to activation time, so no RuntimeError here)
|
|
855
|
+
config = _load_mcp_config_top_level(Path(config_path))
|
|
856
|
+
_validate_mcp_config_servers(config)
|
|
857
|
+
|
|
858
|
+
return config
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _resolve_project_config_base(project_context: ProjectContext | None) -> Path:
|
|
862
|
+
"""Resolve the base directory for project-level MCP configuration lookup.
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
project_context: Explicit project path context, if available.
|
|
866
|
+
|
|
867
|
+
Returns:
|
|
868
|
+
Project root when one exists, otherwise the user working directory.
|
|
869
|
+
"""
|
|
870
|
+
if project_context is not None:
|
|
871
|
+
return project_context.project_root or project_context.user_cwd
|
|
872
|
+
|
|
873
|
+
from deepagents_code.project_utils import find_project_root
|
|
874
|
+
|
|
875
|
+
return find_project_root() or Path.cwd()
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
MCP_CONFIG_DISCOVERY_PATHS: tuple[tuple[str, str], ...] = (
|
|
879
|
+
("~/.zjcode/.mcp.json", "user-level"),
|
|
880
|
+
("<project-root>/.deepagents/.mcp.json", "project subdir"),
|
|
881
|
+
("<project-root>/.mcp.json", "project root"),
|
|
882
|
+
)
|
|
883
|
+
"""Display strings for the auto-discovered MCP config paths.
|
|
884
|
+
|
|
885
|
+
Ordered from lowest to highest precedence. Each entry is `(path, label)`
|
|
886
|
+
suitable for rendering in help screens and error messages. The runtime
|
|
887
|
+
discovery in `discover_mcp_configs` builds the same paths from
|
|
888
|
+
`Path.home()` and `_resolve_project_config_base()`.
|
|
889
|
+
"""
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def discover_mcp_configs(
|
|
893
|
+
*,
|
|
894
|
+
project_context: ProjectContext | None = None,
|
|
895
|
+
) -> list[Path]:
|
|
896
|
+
"""Find MCP config files from standard locations.
|
|
897
|
+
|
|
898
|
+
Checks the paths listed in `MCP_CONFIG_DISCOVERY_PATHS`, lowest to
|
|
899
|
+
highest precedence.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
project_context: Explicit project path context, if available.
|
|
903
|
+
|
|
904
|
+
Returns:
|
|
905
|
+
Existing config file paths, ordered from lowest to highest precedence.
|
|
906
|
+
"""
|
|
907
|
+
user_dir = Path.home() / ".zjcode"
|
|
908
|
+
project_root = _resolve_project_config_base(project_context)
|
|
909
|
+
|
|
910
|
+
candidates = [
|
|
911
|
+
user_dir / ".mcp.json",
|
|
912
|
+
project_root / ".deepagents" / ".mcp.json",
|
|
913
|
+
project_root / ".mcp.json",
|
|
914
|
+
]
|
|
915
|
+
|
|
916
|
+
found: list[Path] = []
|
|
917
|
+
for path in candidates:
|
|
918
|
+
try:
|
|
919
|
+
if path.is_file():
|
|
920
|
+
found.append(path)
|
|
921
|
+
except OSError:
|
|
922
|
+
logger.warning("Could not check MCP config %s", path, exc_info=True)
|
|
923
|
+
return found
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def classify_discovered_configs(
|
|
927
|
+
config_paths: list[Path],
|
|
928
|
+
) -> tuple[list[Path], list[Path]]:
|
|
929
|
+
"""Split discovered config paths into user-level and project-level configs.
|
|
930
|
+
|
|
931
|
+
Args:
|
|
932
|
+
config_paths: Candidate config paths from discovery.
|
|
933
|
+
|
|
934
|
+
Returns:
|
|
935
|
+
Tuple of `(user_configs, project_configs)`.
|
|
936
|
+
"""
|
|
937
|
+
user_dir = Path.home() / ".zjcode"
|
|
938
|
+
user: list[Path] = []
|
|
939
|
+
project: list[Path] = []
|
|
940
|
+
for path in config_paths:
|
|
941
|
+
try:
|
|
942
|
+
if path.resolve().is_relative_to(user_dir.resolve()):
|
|
943
|
+
user.append(path)
|
|
944
|
+
else:
|
|
945
|
+
project.append(path)
|
|
946
|
+
except (OSError, ValueError):
|
|
947
|
+
project.append(path)
|
|
948
|
+
return user, project
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def extract_stdio_server_commands(
|
|
952
|
+
config: dict[str, Any],
|
|
953
|
+
) -> list[tuple[str, str, list[str]]]:
|
|
954
|
+
"""Extract stdio server entries from a parsed MCP config.
|
|
955
|
+
|
|
956
|
+
Args:
|
|
957
|
+
config: Parsed MCP config dictionary.
|
|
958
|
+
|
|
959
|
+
Returns:
|
|
960
|
+
List of `(server_name, command, args)` tuples for stdio servers.
|
|
961
|
+
"""
|
|
962
|
+
results: list[tuple[str, str, list[str]]] = []
|
|
963
|
+
servers = config.get("mcpServers", {})
|
|
964
|
+
if not isinstance(servers, dict):
|
|
965
|
+
return results
|
|
966
|
+
for name, server in servers.items():
|
|
967
|
+
if not isinstance(server, dict):
|
|
968
|
+
continue
|
|
969
|
+
if _resolve_server_type(server) == "stdio":
|
|
970
|
+
results.append((name, server.get("command", ""), server.get("args", [])))
|
|
971
|
+
return results
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def extract_project_server_summaries(
|
|
975
|
+
config: dict[str, Any],
|
|
976
|
+
) -> list[tuple[str, str, str]]:
|
|
977
|
+
"""Return `(name, kind, summary)` for every server in a project config.
|
|
978
|
+
|
|
979
|
+
Used by the trust prompt and the untrusted-config skip warning so that
|
|
980
|
+
both stdio servers (which spawn local commands) and remote servers
|
|
981
|
+
(which can SSRF or exfiltrate environment variables via interpolated
|
|
982
|
+
headers when an attacker controls `.mcp.json`) are gated identically.
|
|
983
|
+
|
|
984
|
+
Args:
|
|
985
|
+
config: Parsed MCP config dictionary.
|
|
986
|
+
|
|
987
|
+
Returns:
|
|
988
|
+
List of `(server_name, kind, summary)`. `kind` is `"stdio"`,
|
|
989
|
+
`"http"`, `"sse"`, or `"unknown"`. `summary` is `"<command> <args>"`
|
|
990
|
+
for stdio entries and the URL for remote entries.
|
|
991
|
+
"""
|
|
992
|
+
results: list[tuple[str, str, str]] = []
|
|
993
|
+
servers = config.get("mcpServers", {})
|
|
994
|
+
if not isinstance(servers, dict):
|
|
995
|
+
return results
|
|
996
|
+
for name, server in servers.items():
|
|
997
|
+
if not isinstance(server, dict):
|
|
998
|
+
logger.debug(
|
|
999
|
+
"Skipping malformed MCP server entry %r: expected a table, got %s",
|
|
1000
|
+
name,
|
|
1001
|
+
type(server).__name__,
|
|
1002
|
+
)
|
|
1003
|
+
continue
|
|
1004
|
+
kind = _resolve_server_type(server)
|
|
1005
|
+
if kind == "stdio":
|
|
1006
|
+
args = server.get("args") or []
|
|
1007
|
+
summary = f"{server.get('command', '')} {' '.join(args)}".strip()
|
|
1008
|
+
elif kind in _SUPPORTED_REMOTE_TYPES:
|
|
1009
|
+
summary = str(server.get("url", ""))
|
|
1010
|
+
else:
|
|
1011
|
+
summary = ""
|
|
1012
|
+
results.append((name, kind, summary))
|
|
1013
|
+
return results
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def merge_mcp_configs(configs: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1017
|
+
"""Merge multiple MCP config dicts by server name.
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
configs: Config dictionaries in ascending precedence order.
|
|
1021
|
+
|
|
1022
|
+
Returns:
|
|
1023
|
+
A single config dict with later server definitions overriding earlier ones.
|
|
1024
|
+
"""
|
|
1025
|
+
merged: dict[str, Any] = {}
|
|
1026
|
+
for config in configs:
|
|
1027
|
+
servers = config.get("mcpServers")
|
|
1028
|
+
if isinstance(servers, dict):
|
|
1029
|
+
merged.update(servers)
|
|
1030
|
+
return {"mcpServers": merged}
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def load_mcp_config_lenient(config_path: Path) -> dict[str, Any] | None:
|
|
1034
|
+
"""Load an MCP config file, returning `None` on any error.
|
|
1035
|
+
|
|
1036
|
+
Args:
|
|
1037
|
+
config_path: Config path to load.
|
|
1038
|
+
|
|
1039
|
+
Returns:
|
|
1040
|
+
The parsed config, or `None` if loading or validation fails.
|
|
1041
|
+
"""
|
|
1042
|
+
config, _ = load_mcp_config_with_error(config_path)
|
|
1043
|
+
return config
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def load_mcp_config_with_error(
|
|
1047
|
+
config_path: Path,
|
|
1048
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
1049
|
+
"""Load an MCP config file, returning `(config, error)`.
|
|
1050
|
+
|
|
1051
|
+
Missing files yield `(None, None)` — not an error. Malformed files
|
|
1052
|
+
yield `(None, error_text)` so callers can surface the reason to users.
|
|
1053
|
+
|
|
1054
|
+
Args:
|
|
1055
|
+
config_path: Config path to load.
|
|
1056
|
+
|
|
1057
|
+
Returns:
|
|
1058
|
+
`(parsed_config, None)` on success, `(None, None)` when the file
|
|
1059
|
+
doesn't exist, or `(None, error_message)` on load/validate failure.
|
|
1060
|
+
"""
|
|
1061
|
+
try:
|
|
1062
|
+
return load_mcp_config(str(config_path)), None
|
|
1063
|
+
except FileNotFoundError:
|
|
1064
|
+
return None, None
|
|
1065
|
+
except OSError as exc:
|
|
1066
|
+
logger.warning("Skipping unreadable MCP config %s: %s", config_path, exc)
|
|
1067
|
+
return None, f"Unreadable: {exc}"
|
|
1068
|
+
except (json.JSONDecodeError, ValueError, TypeError, RuntimeError) as exc:
|
|
1069
|
+
logger.warning("Skipping invalid MCP config %s: %s", config_path, exc)
|
|
1070
|
+
return None, str(exc)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _load_mcp_config_top_level_with_error(
|
|
1074
|
+
config_path: Path,
|
|
1075
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
1076
|
+
"""Load an MCP config file, validating only its top-level structure.
|
|
1077
|
+
|
|
1078
|
+
Args:
|
|
1079
|
+
config_path: Config path to load.
|
|
1080
|
+
|
|
1081
|
+
Returns:
|
|
1082
|
+
`(parsed_config, None)` on success, `(None, None)` when the file
|
|
1083
|
+
doesn't exist, or `(None, error_message)` on load/top-level validate
|
|
1084
|
+
failure.
|
|
1085
|
+
"""
|
|
1086
|
+
try:
|
|
1087
|
+
return _load_mcp_config_top_level(config_path), None
|
|
1088
|
+
except FileNotFoundError:
|
|
1089
|
+
return None, None
|
|
1090
|
+
except OSError as exc:
|
|
1091
|
+
logger.warning("Skipping unreadable MCP config %s: %s", config_path, exc)
|
|
1092
|
+
return None, f"Unreadable: {exc}"
|
|
1093
|
+
except (json.JSONDecodeError, ValueError, TypeError) as exc:
|
|
1094
|
+
logger.warning("Skipping invalid MCP config %s: %s", config_path, exc)
|
|
1095
|
+
return None, str(exc)
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _check_stdio_server(server_name: str, server_config: dict[str, Any]) -> None:
|
|
1099
|
+
"""Verify that a stdio server's command exists on PATH.
|
|
1100
|
+
|
|
1101
|
+
Args:
|
|
1102
|
+
server_name: Server name for error messages.
|
|
1103
|
+
server_config: Validated server config.
|
|
1104
|
+
|
|
1105
|
+
Raises:
|
|
1106
|
+
RuntimeError: If the command is missing or not found on PATH.
|
|
1107
|
+
"""
|
|
1108
|
+
command = server_config.get("command")
|
|
1109
|
+
if command is None:
|
|
1110
|
+
msg = f"MCP server '{server_name}': missing 'command' in config."
|
|
1111
|
+
raise RuntimeError(msg)
|
|
1112
|
+
if shutil.which(command) is None:
|
|
1113
|
+
msg = (
|
|
1114
|
+
f"MCP server '{server_name}': configured command not found on PATH. "
|
|
1115
|
+
"Install it or check your MCP config."
|
|
1116
|
+
)
|
|
1117
|
+
raise RuntimeError(msg)
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
async def _check_remote_server(server_name: str, server_config: dict[str, Any]) -> None:
|
|
1121
|
+
"""Check network connectivity to a remote MCP server URL.
|
|
1122
|
+
|
|
1123
|
+
Args:
|
|
1124
|
+
server_name: Server name for error messages.
|
|
1125
|
+
server_config: Validated remote server config.
|
|
1126
|
+
|
|
1127
|
+
Raises:
|
|
1128
|
+
RuntimeError: If the URL is missing, unreachable, or returns 5xx.
|
|
1129
|
+
"""
|
|
1130
|
+
import httpx
|
|
1131
|
+
|
|
1132
|
+
url = server_config.get("url")
|
|
1133
|
+
if url is None:
|
|
1134
|
+
msg = f"MCP server '{server_name}': missing 'url' in config."
|
|
1135
|
+
raise RuntimeError(msg)
|
|
1136
|
+
try:
|
|
1137
|
+
async with httpx.AsyncClient(timeout=2.0) as client:
|
|
1138
|
+
response = await client.head(url)
|
|
1139
|
+
except (httpx.HTTPError, httpx.InvalidURL, OSError) as exc:
|
|
1140
|
+
# Name the failure *class* (e.g. `ConnectTimeout`, `InvalidURL`) so the
|
|
1141
|
+
# failure mode stays diagnosable, but keep the URL redacted: `str(exc)`
|
|
1142
|
+
# echoes the URL (which may carry `${VAR}`-injected credentials), while
|
|
1143
|
+
# the class name never does.
|
|
1144
|
+
msg = (
|
|
1145
|
+
f"MCP server '{server_name}': configured URL is unreachable "
|
|
1146
|
+
f"({type(exc).__name__}). "
|
|
1147
|
+
"Check that the URL is correct and the server is running."
|
|
1148
|
+
)
|
|
1149
|
+
raise RuntimeError(msg) from exc
|
|
1150
|
+
if response.status_code >= 500: # noqa: PLR2004 # HTTP server-error band
|
|
1151
|
+
msg = (
|
|
1152
|
+
f"MCP server '{server_name}': configured URL returned HTTP "
|
|
1153
|
+
f"{response.status_code}. Server may be down; retry later."
|
|
1154
|
+
)
|
|
1155
|
+
raise RuntimeError(msg)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _config_uses_env_interpolation(server_config: dict[str, Any]) -> bool:
|
|
1159
|
+
"""Return whether a supported config value contains an env reference.
|
|
1160
|
+
|
|
1161
|
+
Exceptions raised after interpolation may include resolved connection
|
|
1162
|
+
values in their messages or traceback. Treat every environment-derived
|
|
1163
|
+
value as potentially sensitive so those failures can be reported without
|
|
1164
|
+
exposing the resolved value.
|
|
1165
|
+
|
|
1166
|
+
Args:
|
|
1167
|
+
server_config: Raw, unresolved MCP server configuration.
|
|
1168
|
+
|
|
1169
|
+
Returns:
|
|
1170
|
+
Whether a supported value contains a `${...}` reference.
|
|
1171
|
+
"""
|
|
1172
|
+
scalar_values = [server_config.get("command"), server_config.get("url")]
|
|
1173
|
+
sequence_values = server_config.get("args")
|
|
1174
|
+
if isinstance(sequence_values, list):
|
|
1175
|
+
scalar_values.extend(sequence_values)
|
|
1176
|
+
for field in ("env", "headers"):
|
|
1177
|
+
mapping = server_config.get(field)
|
|
1178
|
+
if isinstance(mapping, dict):
|
|
1179
|
+
scalar_values.extend(mapping.values())
|
|
1180
|
+
return any(isinstance(value, str) and "${" in value for value in scalar_values)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
async def _discover_tools(session: ClientSession) -> list[Any]:
|
|
1184
|
+
"""Enumerate MCP tools from `session`, paginating until exhausted.
|
|
1185
|
+
|
|
1186
|
+
Args:
|
|
1187
|
+
session: Initialized MCP client session.
|
|
1188
|
+
|
|
1189
|
+
Returns:
|
|
1190
|
+
Discovered MCP tool definitions.
|
|
1191
|
+
|
|
1192
|
+
Raises:
|
|
1193
|
+
RuntimeError: If pagination never terminates within the hard safety bound.
|
|
1194
|
+
"""
|
|
1195
|
+
cursor: str | None = None
|
|
1196
|
+
tools: list[Any] = []
|
|
1197
|
+
for _ in range(1000):
|
|
1198
|
+
page = await session.list_tools(cursor=cursor)
|
|
1199
|
+
if page.tools:
|
|
1200
|
+
tools.extend(page.tools)
|
|
1201
|
+
if not page.nextCursor:
|
|
1202
|
+
return tools
|
|
1203
|
+
cursor = page.nextCursor
|
|
1204
|
+
msg = (
|
|
1205
|
+
"Reached max of 1000 iterations while listing MCP tools; "
|
|
1206
|
+
"server may be returning a non-terminating cursor."
|
|
1207
|
+
)
|
|
1208
|
+
raise RuntimeError(msg)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _normalize_mcp_arguments(
|
|
1212
|
+
arguments: dict[str, Any],
|
|
1213
|
+
input_schema: Any, # noqa: ANN401 # raw JSON Schema dict from the MCP tool
|
|
1214
|
+
) -> dict[str, Any]:
|
|
1215
|
+
"""Drop empty-string values for optional MCP tool params.
|
|
1216
|
+
|
|
1217
|
+
Some MCP servers (e.g. Slack's `slack_search_public_and_private`) validate
|
|
1218
|
+
optional ID-typed params with `value is not a channel ID` when the model
|
|
1219
|
+
fills them in with `""` instead of omitting them. JSON-Schema-derived
|
|
1220
|
+
Pydantic models happily accept `""` for `Optional[str]`, so the request
|
|
1221
|
+
reaches the server and gets rejected with a generic `ToolException`.
|
|
1222
|
+
|
|
1223
|
+
Treat `""` for non-required string fields as "omitted" so the MCP server
|
|
1224
|
+
sees the same payload it would have for a field the model genuinely
|
|
1225
|
+
skipped. Required fields are passed through unchanged so the server's
|
|
1226
|
+
own missing-field error path still runs when applicable.
|
|
1227
|
+
|
|
1228
|
+
Only `""` is normalized; `None` is left to the caller / server. Schemas
|
|
1229
|
+
that declare `["string", "null"]` will see `""` dropped but `None`
|
|
1230
|
+
forwarded — callers that want symmetric "no value" handling should
|
|
1231
|
+
omit the kwarg explicitly.
|
|
1232
|
+
|
|
1233
|
+
Dropped keys are logged at debug so unexpected MCP behavior is
|
|
1234
|
+
diagnosable when a tool semantically distinguishes `""` from omitted.
|
|
1235
|
+
|
|
1236
|
+
Args:
|
|
1237
|
+
arguments: Keyword arguments collected by LangChain's tool runner.
|
|
1238
|
+
input_schema: The MCP tool's `inputSchema` (raw JSON Schema dict).
|
|
1239
|
+
|
|
1240
|
+
Returns:
|
|
1241
|
+
A new dict suitable for `session.call_tool`.
|
|
1242
|
+
"""
|
|
1243
|
+
if not isinstance(input_schema, dict):
|
|
1244
|
+
return arguments
|
|
1245
|
+
required = set(input_schema.get("required") or ())
|
|
1246
|
+
properties = input_schema.get("properties") or {}
|
|
1247
|
+
cleaned: dict[str, Any] = {}
|
|
1248
|
+
for key, value in arguments.items():
|
|
1249
|
+
if value != "" or key in required: # noqa: PLC1901 # distinguishing "" from other falsy types (0, False, []) is the point
|
|
1250
|
+
cleaned[key] = value
|
|
1251
|
+
continue
|
|
1252
|
+
prop = properties.get(key)
|
|
1253
|
+
prop_type = prop.get("type") if isinstance(prop, dict) else None
|
|
1254
|
+
is_string_typed = prop_type == "string" or (
|
|
1255
|
+
isinstance(prop_type, list) and "string" in prop_type
|
|
1256
|
+
)
|
|
1257
|
+
# Three drop conditions converge here:
|
|
1258
|
+
# - explicit string type (the original Slack-style failure mode);
|
|
1259
|
+
# - missing `type` (oneOf/anyOf/$ref or untyped — treat as ambiguous
|
|
1260
|
+
# and conservatively drop, since the server will reject `""` for
|
|
1261
|
+
# any ID-shaped slot anyway);
|
|
1262
|
+
# - key absent from `properties` entirely (model invented a field).
|
|
1263
|
+
# Anything with an explicit non-string `type` is kept — `""` can't be
|
|
1264
|
+
# a valid integer/bool/array so it was the model's mistake to send,
|
|
1265
|
+
# and the server's own validation gives a clearer error than ours.
|
|
1266
|
+
if isinstance(prop, dict) and not is_string_typed and prop_type is not None:
|
|
1267
|
+
cleaned[key] = value
|
|
1268
|
+
if cleaned.keys() != arguments.keys():
|
|
1269
|
+
dropped = sorted(set(arguments) - set(cleaned))
|
|
1270
|
+
logger.debug("MCP arg normalize: dropped empty-string keys %s", dropped)
|
|
1271
|
+
return cleaned
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _build_cached_mcp_tool(
|
|
1275
|
+
*,
|
|
1276
|
+
mcp_tool: Any, # noqa: ANN401
|
|
1277
|
+
server_name: str,
|
|
1278
|
+
session_manager: MCPSessionManager,
|
|
1279
|
+
tool_name_prefix: bool,
|
|
1280
|
+
) -> BaseTool:
|
|
1281
|
+
"""Build a `StructuredTool` backed by the cached session manager.
|
|
1282
|
+
|
|
1283
|
+
Args:
|
|
1284
|
+
mcp_tool: MCP tool metadata object.
|
|
1285
|
+
server_name: Owning MCP server name.
|
|
1286
|
+
session_manager: Runtime session cache used for tool calls.
|
|
1287
|
+
tool_name_prefix: Whether to prefix the LangChain tool name with the
|
|
1288
|
+
server name.
|
|
1289
|
+
|
|
1290
|
+
Returns:
|
|
1291
|
+
A LangChain `BaseTool` wrapper around the MCP tool.
|
|
1292
|
+
"""
|
|
1293
|
+
from langchain_core.tools import StructuredTool, ToolException
|
|
1294
|
+
from langchain_mcp_adapters.tools import (
|
|
1295
|
+
_convert_call_tool_result, # noqa: PLC2701
|
|
1296
|
+
_handle_mcp_tool_error, # noqa: PLC2701
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
original_tool_name = mcp_tool.name
|
|
1300
|
+
lc_tool_name = (
|
|
1301
|
+
f"{server_name}_{original_tool_name}"
|
|
1302
|
+
if tool_name_prefix and server_name
|
|
1303
|
+
else original_tool_name
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
meta = getattr(mcp_tool, "meta", None)
|
|
1307
|
+
base_meta = (
|
|
1308
|
+
mcp_tool.annotations.model_dump() if mcp_tool.annotations is not None else {}
|
|
1309
|
+
)
|
|
1310
|
+
wrapped_meta = {"_meta": meta} if meta is not None else {}
|
|
1311
|
+
metadata = {**base_meta, **wrapped_meta} or None
|
|
1312
|
+
|
|
1313
|
+
def _handle_cached_mcp_tool_error(error: ToolException) -> Any: # noqa: ANN401
|
|
1314
|
+
try:
|
|
1315
|
+
return _handle_mcp_tool_error(error)
|
|
1316
|
+
except ToolException:
|
|
1317
|
+
logger.warning(
|
|
1318
|
+
"MCP tool %r failed with recoverable ToolException: %s",
|
|
1319
|
+
lc_tool_name,
|
|
1320
|
+
error,
|
|
1321
|
+
exc_info=True,
|
|
1322
|
+
)
|
|
1323
|
+
return str(error) or f"{lc_tool_name} failed with no error detail"
|
|
1324
|
+
|
|
1325
|
+
async def coroutine(
|
|
1326
|
+
# `runtime` is injected by LangChain's tool-calling plumbing.
|
|
1327
|
+
# MCP tools don't use it but the kwarg must still be accepted.
|
|
1328
|
+
runtime: Any = None, # noqa: ANN401, ARG001
|
|
1329
|
+
**arguments: Any,
|
|
1330
|
+
) -> Any: # noqa: ANN401
|
|
1331
|
+
from deepagents_code.mcp_auth import find_reauth_required
|
|
1332
|
+
|
|
1333
|
+
arguments = _normalize_mcp_arguments(arguments, mcp_tool.inputSchema)
|
|
1334
|
+
|
|
1335
|
+
session = await session_manager.get_session(server_name)
|
|
1336
|
+
try:
|
|
1337
|
+
result = await session.call_tool(original_tool_name, arguments)
|
|
1338
|
+
# Re-raise control-flow/shutdown signals (CancelledError,
|
|
1339
|
+
# KeyboardInterrupt, SystemExit) and ToolException unchanged. Wrapping a
|
|
1340
|
+
# ToolException here would bury its actionable message (e.g. an MCP
|
|
1341
|
+
# `isError` instruction like "use the X tool instead") under a generic
|
|
1342
|
+
# retry wrapper; re-raising preserves it for the tool-local error
|
|
1343
|
+
# handler and the model.
|
|
1344
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit, ToolException):
|
|
1345
|
+
raise
|
|
1346
|
+
except Exception as exc:
|
|
1347
|
+
reauth = find_reauth_required(exc)
|
|
1348
|
+
if reauth is not None:
|
|
1349
|
+
await session_manager.invalidate(
|
|
1350
|
+
server_name,
|
|
1351
|
+
expected_session=session,
|
|
1352
|
+
)
|
|
1353
|
+
raise ToolException(str(reauth)) from exc
|
|
1354
|
+
if not _is_transient_session_error(exc):
|
|
1355
|
+
msg = (
|
|
1356
|
+
f"MCP tool {lc_tool_name!r} failed on server "
|
|
1357
|
+
f"{server_name!r}: {type(exc).__name__}: {exc}"
|
|
1358
|
+
)
|
|
1359
|
+
raise ToolException(msg) from exc
|
|
1360
|
+
logger.info(
|
|
1361
|
+
"MCP session for %r appears dead (%s: %s); "
|
|
1362
|
+
"invalidating and retrying once",
|
|
1363
|
+
server_name,
|
|
1364
|
+
type(exc).__name__,
|
|
1365
|
+
exc,
|
|
1366
|
+
)
|
|
1367
|
+
await session_manager.invalidate(
|
|
1368
|
+
server_name,
|
|
1369
|
+
expected_session=session,
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
retry_session = await session_manager.get_session(server_name)
|
|
1373
|
+
try:
|
|
1374
|
+
result = await retry_session.call_tool(original_tool_name, arguments)
|
|
1375
|
+
except (
|
|
1376
|
+
asyncio.CancelledError,
|
|
1377
|
+
KeyboardInterrupt,
|
|
1378
|
+
SystemExit,
|
|
1379
|
+
ToolException,
|
|
1380
|
+
):
|
|
1381
|
+
raise
|
|
1382
|
+
except Exception as retry_exc: # noqa: BLE001 - wrapped into ToolException below so the agent sees it
|
|
1383
|
+
try:
|
|
1384
|
+
retry_reauth = find_reauth_required(retry_exc)
|
|
1385
|
+
if retry_reauth is not None:
|
|
1386
|
+
raise ToolException(str(retry_reauth)) from retry_exc
|
|
1387
|
+
msg = (
|
|
1388
|
+
f"MCP tool {lc_tool_name!r} failed after one retry on "
|
|
1389
|
+
f"server {server_name!r}: {type(retry_exc).__name__}: "
|
|
1390
|
+
f"{retry_exc}"
|
|
1391
|
+
)
|
|
1392
|
+
raise ToolException(msg) from retry_exc
|
|
1393
|
+
finally:
|
|
1394
|
+
# Invalidate the retry session last; log cleanup failure
|
|
1395
|
+
# so resource leaks are observable.
|
|
1396
|
+
try:
|
|
1397
|
+
await session_manager.invalidate(
|
|
1398
|
+
server_name,
|
|
1399
|
+
expected_session=retry_session,
|
|
1400
|
+
)
|
|
1401
|
+
except Exception:
|
|
1402
|
+
logger.warning(
|
|
1403
|
+
"Failed to invalidate retry session for %r after "
|
|
1404
|
+
"tool failure",
|
|
1405
|
+
server_name,
|
|
1406
|
+
exc_info=True,
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
# On an MCP `isError=True` result the adapter's `_convert_call_tool_result`
|
|
1410
|
+
# raises, and the `handle_tool_error` callback registered below converts
|
|
1411
|
+
# the MCP content blocks into a `ToolMessage(status="error")`. Other
|
|
1412
|
+
# expected `ToolException`s raised by this wrapper are formatted by that
|
|
1413
|
+
# same tool-local handler.
|
|
1414
|
+
return _convert_call_tool_result(result)
|
|
1415
|
+
|
|
1416
|
+
return StructuredTool(
|
|
1417
|
+
name=lc_tool_name,
|
|
1418
|
+
description=mcp_tool.description or "",
|
|
1419
|
+
args_schema=mcp_tool.inputSchema,
|
|
1420
|
+
coroutine=coroutine,
|
|
1421
|
+
response_format="content_and_artifact",
|
|
1422
|
+
metadata=metadata,
|
|
1423
|
+
handle_tool_error=cast("Any", _handle_cached_mcp_tool_error),
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
_GLOB_METACHARS = frozenset("*?[")
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def _entry_matches_tool(entry: str, tool_name: str, prefix: str) -> bool:
|
|
1431
|
+
"""Return True if a single filter entry matches a tool name.
|
|
1432
|
+
|
|
1433
|
+
An entry containing `*`, `?`, or `[` is treated as an `fnmatch`-style glob;
|
|
1434
|
+
otherwise it is matched literally. Each entry is tried against both the
|
|
1435
|
+
bare MCP tool name and the server-prefixed form (`f"{prefix}{tool}"`), so
|
|
1436
|
+
users can write either `read_*` or `fs_read_*`.
|
|
1437
|
+
|
|
1438
|
+
Args:
|
|
1439
|
+
entry: Filter list entry from `allowedTools` / `disabledTools`.
|
|
1440
|
+
tool_name: Adapter-supplied tool name (already server-prefixed).
|
|
1441
|
+
prefix: Server prefix (`f"{server_name}_"`).
|
|
1442
|
+
|
|
1443
|
+
Returns:
|
|
1444
|
+
True if the entry matches this tool under either match mode.
|
|
1445
|
+
"""
|
|
1446
|
+
is_glob = any(ch in _GLOB_METACHARS for ch in entry)
|
|
1447
|
+
if is_glob:
|
|
1448
|
+
if fnmatch.fnmatchcase(tool_name, entry):
|
|
1449
|
+
return True
|
|
1450
|
+
if tool_name.startswith(prefix):
|
|
1451
|
+
return fnmatch.fnmatchcase(tool_name[len(prefix) :], entry)
|
|
1452
|
+
return False
|
|
1453
|
+
if tool_name == entry:
|
|
1454
|
+
return True
|
|
1455
|
+
return tool_name.startswith(prefix) and tool_name[len(prefix) :] == entry
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
@overload
|
|
1459
|
+
def _apply_tool_filter(
|
|
1460
|
+
tools: list[BaseTool],
|
|
1461
|
+
server_name: str,
|
|
1462
|
+
server_config: dict[str, Any],
|
|
1463
|
+
) -> list[BaseTool]: ...
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
@overload
|
|
1467
|
+
def _apply_tool_filter(
|
|
1468
|
+
tools: Sequence[BaseTool],
|
|
1469
|
+
server_name: str,
|
|
1470
|
+
server_config: dict[str, Any],
|
|
1471
|
+
) -> Sequence[BaseTool]: ...
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _apply_tool_filter(
|
|
1475
|
+
tools: Sequence[BaseTool],
|
|
1476
|
+
server_name: str,
|
|
1477
|
+
server_config: dict[str, Any],
|
|
1478
|
+
) -> Sequence[BaseTool]:
|
|
1479
|
+
"""Filter a server's loaded tools by its `allowedTools` / `disabledTools`.
|
|
1480
|
+
|
|
1481
|
+
Entries may be literal tool names or `fnmatch`-style glob patterns
|
|
1482
|
+
(entries containing `*`, `?`, or `[`). Each entry is tried against both
|
|
1483
|
+
the bare MCP tool name and the server-prefixed name produced by
|
|
1484
|
+
`tool_name_prefix=True` (`f"{server_name}_{tool}"`). Entries that match
|
|
1485
|
+
no loaded tool are logged but not an error — the underlying MCP server
|
|
1486
|
+
may expose different tools across versions, so a stale entry should not
|
|
1487
|
+
fail startup. The same warning is emitted symmetrically for both fields
|
|
1488
|
+
so a typo in `disabledTools` is visible (otherwise a tool the user
|
|
1489
|
+
intended to disable would silently remain enabled).
|
|
1490
|
+
|
|
1491
|
+
Args:
|
|
1492
|
+
tools: Tools returned by `load_mcp_tools` for a single server.
|
|
1493
|
+
server_name: Server name used by the adapter to build the prefix.
|
|
1494
|
+
server_config: Server config dict (read for filter fields).
|
|
1495
|
+
|
|
1496
|
+
Returns:
|
|
1497
|
+
Filtered tool list preserving input order.
|
|
1498
|
+
"""
|
|
1499
|
+
allowed: list[str] | None = server_config.get("allowedTools")
|
|
1500
|
+
disabled: list[str] | None = server_config.get("disabledTools")
|
|
1501
|
+
entries: list[str] | None = allowed if allowed is not None else disabled
|
|
1502
|
+
if entries is None:
|
|
1503
|
+
return tools
|
|
1504
|
+
|
|
1505
|
+
prefix = f"{server_name}_"
|
|
1506
|
+
field_name = "allowedTools" if allowed is not None else "disabledTools"
|
|
1507
|
+
|
|
1508
|
+
def _any_entry_matches(tool_name: str, entry_list: list[str]) -> bool:
|
|
1509
|
+
return any(_entry_matches_tool(e, tool_name, prefix) for e in entry_list)
|
|
1510
|
+
|
|
1511
|
+
missing = [
|
|
1512
|
+
e
|
|
1513
|
+
for e in entries
|
|
1514
|
+
if not any(_entry_matches_tool(e, t.name, prefix) for t in tools)
|
|
1515
|
+
]
|
|
1516
|
+
if missing:
|
|
1517
|
+
logger.warning(
|
|
1518
|
+
"MCP server '%s' %s entries matched no tools: %s",
|
|
1519
|
+
server_name,
|
|
1520
|
+
field_name,
|
|
1521
|
+
", ".join(missing),
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
if allowed is not None:
|
|
1525
|
+
return [t for t in tools if _any_entry_matches(t.name, entries)]
|
|
1526
|
+
return [t for t in tools if not _any_entry_matches(t.name, entries)]
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
_MCP_LOAD_CONCURRENCY = 8
|
|
1530
|
+
"""Upper bound on MCP servers preflighted/discovered concurrently.
|
|
1531
|
+
|
|
1532
|
+
Independent servers are probed in parallel so graph load no longer scales
|
|
1533
|
+
linearly with server count, but the fan-out is capped so a large config cannot
|
|
1534
|
+
spawn an unbounded number of simultaneous socket/subprocess handshakes (or
|
|
1535
|
+
`asyncio.to_thread` `shutil.which` workers).
|
|
1536
|
+
"""
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _warm_mcp_adapter_imports() -> None:
|
|
1540
|
+
"""Eagerly import MCP adapter modules whose first import may block.
|
|
1541
|
+
|
|
1542
|
+
Run via `asyncio.to_thread` before adapter symbols are used, so the initial
|
|
1543
|
+
(potentially blocking) package-resource scan stays off the server event
|
|
1544
|
+
loop. Because this runs inside `_load_tools_from_config`, it happens only
|
|
1545
|
+
when at least one active MCP server exists — a config with no MCP servers
|
|
1546
|
+
never imports the adapters.
|
|
1547
|
+
"""
|
|
1548
|
+
from langchain_mcp_adapters import (
|
|
1549
|
+
sessions as _sessions, # noqa: F401
|
|
1550
|
+
tools as _tools, # noqa: F401
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
async def _gather_bounded(
|
|
1555
|
+
factories: Sequence[Callable[[], Awaitable[_T]]],
|
|
1556
|
+
*,
|
|
1557
|
+
limit: int,
|
|
1558
|
+
) -> list[_T]:
|
|
1559
|
+
"""Await coroutine factories with bounded concurrency, preserving order.
|
|
1560
|
+
|
|
1561
|
+
Results are returned in submission order (not completion order), so callers
|
|
1562
|
+
can zip them back against their inputs to keep deterministic ordering. If a
|
|
1563
|
+
factory raises (including a cancellation/shutdown signal), the remaining
|
|
1564
|
+
tasks are cancelled and awaited before the exception propagates, so no
|
|
1565
|
+
background work is left running.
|
|
1566
|
+
|
|
1567
|
+
`asyncio.gather` propagates only the *first* task to finish with an
|
|
1568
|
+
exception; when several tasks fail concurrently the rest are cancelled
|
|
1569
|
+
during teardown and their exceptions would otherwise be discarded silently.
|
|
1570
|
+
To keep concurrent failures debuggable, each dropped (non-cancellation)
|
|
1571
|
+
sibling exception is logged at debug level before the first one propagates.
|
|
1572
|
+
|
|
1573
|
+
Args:
|
|
1574
|
+
factories: Zero-arg callables each returning an awaitable to run.
|
|
1575
|
+
limit: Maximum number of awaitables in flight at once. Values below 1
|
|
1576
|
+
are clamped to 1.
|
|
1577
|
+
|
|
1578
|
+
Returns:
|
|
1579
|
+
The awaited results in the same order as `factories`.
|
|
1580
|
+
"""
|
|
1581
|
+
semaphore = asyncio.Semaphore(max(1, limit))
|
|
1582
|
+
|
|
1583
|
+
async def _run(factory: Callable[[], Awaitable[_T]]) -> _T:
|
|
1584
|
+
async with semaphore:
|
|
1585
|
+
return await factory()
|
|
1586
|
+
|
|
1587
|
+
tasks = [asyncio.create_task(_run(factory)) for factory in factories]
|
|
1588
|
+
try:
|
|
1589
|
+
return await asyncio.gather(*tasks)
|
|
1590
|
+
except BaseException:
|
|
1591
|
+
for task in tasks:
|
|
1592
|
+
task.cancel()
|
|
1593
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
1594
|
+
for result in results:
|
|
1595
|
+
if isinstance(result, BaseException) and not isinstance(
|
|
1596
|
+
result, asyncio.CancelledError
|
|
1597
|
+
):
|
|
1598
|
+
logger.debug(
|
|
1599
|
+
"MCP concurrent load: a sibling task failed while another "
|
|
1600
|
+
"failure was already propagating; logging the dropped "
|
|
1601
|
+
"exception for debugging",
|
|
1602
|
+
exc_info=result,
|
|
1603
|
+
)
|
|
1604
|
+
raise
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
async def _load_tools_from_config(
|
|
1608
|
+
config: dict[str, Any],
|
|
1609
|
+
*,
|
|
1610
|
+
stateless: bool = False,
|
|
1611
|
+
session_manager: MCPSessionManager | None = None,
|
|
1612
|
+
) -> tuple[list[BaseTool], MCPSessionManager | None, list[MCPServerInfo]]:
|
|
1613
|
+
"""Build MCP connections from a validated config and load tools.
|
|
1614
|
+
|
|
1615
|
+
Discovery always opens throwaway sessions to capture tool metadata only.
|
|
1616
|
+
Runtime tools either:
|
|
1617
|
+
|
|
1618
|
+
- bind to a caller-managed `session_manager` (server mode),
|
|
1619
|
+
- bind to a new local `session_manager` returned to the caller, or
|
|
1620
|
+
- stay fully stateless and open a fresh session per tool call.
|
|
1621
|
+
|
|
1622
|
+
Per-server config/auth/setup failures are captured in the returned
|
|
1623
|
+
`server_infos` list rather than propagated — one bad server never
|
|
1624
|
+
hides the others.
|
|
1625
|
+
|
|
1626
|
+
Args:
|
|
1627
|
+
config: Validated MCP configuration dict with `mcpServers` key.
|
|
1628
|
+
stateless: When `True`, tools avoid returning an owned session manager.
|
|
1629
|
+
session_manager: Optional externally owned runtime session manager.
|
|
1630
|
+
|
|
1631
|
+
Returns:
|
|
1632
|
+
Tuple of `(tools_list, session_manager, server_infos)`.
|
|
1633
|
+
|
|
1634
|
+
Raises:
|
|
1635
|
+
RuntimeError: If `session_manager` is reconfigured incompatibly with
|
|
1636
|
+
sessions already active on it.
|
|
1637
|
+
""" # noqa: DOC502 - `RuntimeError` surfaces via `MCPSessionManager.configure`
|
|
1638
|
+
# Warm the adapter imports off the event loop *here* (rather than in the
|
|
1639
|
+
# caller) so a config with no active MCP servers — which returns before
|
|
1640
|
+
# ever reaching this function — never pays the adapter-import cost.
|
|
1641
|
+
await asyncio.to_thread(_warm_mcp_adapter_imports)
|
|
1642
|
+
from langchain_mcp_adapters.sessions import (
|
|
1643
|
+
SSEConnection,
|
|
1644
|
+
StdioConnection,
|
|
1645
|
+
StreamableHttpConnection,
|
|
1646
|
+
create_session,
|
|
1647
|
+
)
|
|
1648
|
+
from langchain_mcp_adapters.tools import convert_mcp_tool_to_langchain_tool
|
|
1649
|
+
|
|
1650
|
+
server_items = list(config["mcpServers"].items())
|
|
1651
|
+
# Resolve each server's transport once, up front. `_resolve_server_type` is
|
|
1652
|
+
# pure, so this is a readability/DRY win over recomputing it in preflight,
|
|
1653
|
+
# discovery, and the final fold-in loop below.
|
|
1654
|
+
transports = {name: _resolve_server_type(cfg) for name, cfg in server_items}
|
|
1655
|
+
|
|
1656
|
+
async def _preflight_and_connect(
|
|
1657
|
+
server_name: str,
|
|
1658
|
+
server_config: dict[str, Any],
|
|
1659
|
+
) -> tuple[MCPServerStatus, str] | Connection:
|
|
1660
|
+
"""Preflight one server and build its connection config.
|
|
1661
|
+
|
|
1662
|
+
Per-server preflight/config failures are captured here so one bad
|
|
1663
|
+
server never aborts loading the others.
|
|
1664
|
+
|
|
1665
|
+
Returns:
|
|
1666
|
+
A `(status, error)` tuple when the server must be skipped, or a
|
|
1667
|
+
ready `Connection` otherwise.
|
|
1668
|
+
"""
|
|
1669
|
+
server_type = transports[server_name]
|
|
1670
|
+
# Capture this from the *raw* config, before resolution below rebinds
|
|
1671
|
+
# `server_config` to the expanded copy. Once `${...}` refs are expanded,
|
|
1672
|
+
# a downstream setup error may echo the resolved (secret-bearing) value,
|
|
1673
|
+
# so those messages are redacted; plain configs keep full detail.
|
|
1674
|
+
redact_failure_details = _config_uses_env_interpolation(server_config)
|
|
1675
|
+
# Config env-var resolution is the only step that raises `TypeError`
|
|
1676
|
+
# (non-string field). Keep it in its own `try` so an unexpected
|
|
1677
|
+
# `TypeError` from the connectivity checks below — whose contract is
|
|
1678
|
+
# `RuntimeError` only — surfaces as a real bug instead of being
|
|
1679
|
+
# relabeled as a per-server config skip.
|
|
1680
|
+
try:
|
|
1681
|
+
server_config = resolve_mcp_server_env(server_name, server_config)
|
|
1682
|
+
except (RuntimeError, TypeError) as exc:
|
|
1683
|
+
logger.warning(
|
|
1684
|
+
"MCP server '%s' skipped: config error: %s",
|
|
1685
|
+
server_name,
|
|
1686
|
+
exc,
|
|
1687
|
+
)
|
|
1688
|
+
return ("error", str(exc))
|
|
1689
|
+
try:
|
|
1690
|
+
if server_type in _SUPPORTED_REMOTE_TYPES:
|
|
1691
|
+
await _check_remote_server(server_name, server_config)
|
|
1692
|
+
elif server_type == "stdio":
|
|
1693
|
+
# `shutil.which` makes blocking `os.access` calls; run it
|
|
1694
|
+
# off the event loop so blockbuster doesn't reject it.
|
|
1695
|
+
await asyncio.to_thread(_check_stdio_server, server_name, server_config)
|
|
1696
|
+
except RuntimeError as exc:
|
|
1697
|
+
logger.warning(
|
|
1698
|
+
"MCP server '%s' skipped: pre-flight failed: %s",
|
|
1699
|
+
server_name,
|
|
1700
|
+
exc,
|
|
1701
|
+
)
|
|
1702
|
+
return ("error", str(exc))
|
|
1703
|
+
|
|
1704
|
+
try:
|
|
1705
|
+
if server_type in _SUPPORTED_REMOTE_TYPES:
|
|
1706
|
+
if server_type == "http":
|
|
1707
|
+
conn: Connection = StreamableHttpConnection(
|
|
1708
|
+
transport="streamable_http",
|
|
1709
|
+
url=server_config["url"],
|
|
1710
|
+
)
|
|
1711
|
+
else:
|
|
1712
|
+
conn = SSEConnection(
|
|
1713
|
+
transport="sse",
|
|
1714
|
+
url=server_config["url"],
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
if "headers" in server_config:
|
|
1718
|
+
conn["headers"] = server_config["headers"]
|
|
1719
|
+
|
|
1720
|
+
from deepagents_code.mcp_auth import (
|
|
1721
|
+
FileTokenStorage,
|
|
1722
|
+
build_oauth_provider,
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
explicit_oauth = server_config.get("auth") == "oauth"
|
|
1726
|
+
header_names = {
|
|
1727
|
+
name.lower() for name in (server_config.get("headers") or {})
|
|
1728
|
+
}
|
|
1729
|
+
has_authorization_header = "authorization" in header_names
|
|
1730
|
+
storage = FileTokenStorage(
|
|
1731
|
+
server_name,
|
|
1732
|
+
server_url=server_config["url"],
|
|
1733
|
+
)
|
|
1734
|
+
stored_tokens = await storage.get_tokens()
|
|
1735
|
+
|
|
1736
|
+
if explicit_oauth and stored_tokens is None:
|
|
1737
|
+
# Config opted into OAuth but no tokens are stored yet —
|
|
1738
|
+
# require an upfront login before connecting.
|
|
1739
|
+
auth_msg = f"MCP server {server_name!r} needs re-authentication."
|
|
1740
|
+
logger.warning(
|
|
1741
|
+
"MCP server '%s' skipped: not authenticated.",
|
|
1742
|
+
server_name,
|
|
1743
|
+
)
|
|
1744
|
+
return ("unauthenticated", auth_msg)
|
|
1745
|
+
|
|
1746
|
+
if explicit_oauth or (
|
|
1747
|
+
stored_tokens is not None and not has_authorization_header
|
|
1748
|
+
):
|
|
1749
|
+
# Attach the provider when the user opted in, or when a
|
|
1750
|
+
# prior login (possibly triggered by 401 auto-detection)
|
|
1751
|
+
# already stored tokens for this server. Static
|
|
1752
|
+
# Authorization headers take precedence over stored OAuth.
|
|
1753
|
+
conn["auth"] = build_oauth_provider(
|
|
1754
|
+
server_name=server_name,
|
|
1755
|
+
server_url=server_config["url"],
|
|
1756
|
+
storage=storage,
|
|
1757
|
+
interactive=False,
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
return conn
|
|
1761
|
+
return StdioConnection(
|
|
1762
|
+
command=server_config["command"],
|
|
1763
|
+
args=server_config.get("args", []),
|
|
1764
|
+
env=server_config.get("env") or None,
|
|
1765
|
+
transport="stdio",
|
|
1766
|
+
)
|
|
1767
|
+
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
|
1768
|
+
if redact_failure_details:
|
|
1769
|
+
error = (
|
|
1770
|
+
f"MCP server {server_name!r}: setup failed after "
|
|
1771
|
+
"resolving environment variables."
|
|
1772
|
+
)
|
|
1773
|
+
logger.warning(
|
|
1774
|
+
"MCP server '%s' skipped: config/setup failed (%s; details "
|
|
1775
|
+
"redacted because config uses environment interpolation)",
|
|
1776
|
+
server_name,
|
|
1777
|
+
exc.__class__.__name__,
|
|
1778
|
+
)
|
|
1779
|
+
else:
|
|
1780
|
+
error = str(exc)
|
|
1781
|
+
logger.warning(
|
|
1782
|
+
"MCP server '%s' skipped: config/setup failed",
|
|
1783
|
+
server_name,
|
|
1784
|
+
exc_info=exc,
|
|
1785
|
+
)
|
|
1786
|
+
return ("error", error)
|
|
1787
|
+
|
|
1788
|
+
# Preflight + connection build runs concurrently across servers (bounded).
|
|
1789
|
+
# Results come back in submission order, so `skipped`/`connections` are
|
|
1790
|
+
# assembled in config order and stay deterministic regardless of which
|
|
1791
|
+
# server's probe finished first.
|
|
1792
|
+
preflight_results = await _gather_bounded(
|
|
1793
|
+
[
|
|
1794
|
+
functools.partial(_preflight_and_connect, name, cfg)
|
|
1795
|
+
for name, cfg in server_items
|
|
1796
|
+
],
|
|
1797
|
+
limit=_MCP_LOAD_CONCURRENCY,
|
|
1798
|
+
)
|
|
1799
|
+
|
|
1800
|
+
skipped: dict[str, tuple[MCPServerStatus, str]] = {}
|
|
1801
|
+
connections: dict[str, Connection] = {}
|
|
1802
|
+
for (server_name, _server_config), result in zip(
|
|
1803
|
+
server_items, preflight_results, strict=True
|
|
1804
|
+
):
|
|
1805
|
+
if isinstance(result, tuple):
|
|
1806
|
+
skipped[server_name] = result
|
|
1807
|
+
else:
|
|
1808
|
+
connections[server_name] = result
|
|
1809
|
+
|
|
1810
|
+
runtime_manager: MCPSessionManager | None = session_manager
|
|
1811
|
+
if runtime_manager is not None:
|
|
1812
|
+
runtime_manager.configure(connections)
|
|
1813
|
+
elif not stateless:
|
|
1814
|
+
runtime_manager = MCPSessionManager(connections=connections)
|
|
1815
|
+
|
|
1816
|
+
async def _discover_server(
|
|
1817
|
+
server_name: str,
|
|
1818
|
+
server_config: dict[str, Any],
|
|
1819
|
+
transport: str,
|
|
1820
|
+
) -> tuple[list[BaseTool], MCPServerInfo]:
|
|
1821
|
+
"""Discover one server's tools and build its `MCPServerInfo`.
|
|
1822
|
+
|
|
1823
|
+
Both discovery failures (classified as auth vs. generic error) and
|
|
1824
|
+
post-discovery tool-construction failures are captured as a non-`ok`
|
|
1825
|
+
`MCPServerInfo` with no tools, so a single failing server never aborts
|
|
1826
|
+
the load for the others. Cancellation/shutdown signals are re-raised so
|
|
1827
|
+
the bounded runner can tear the whole load down.
|
|
1828
|
+
|
|
1829
|
+
Returns:
|
|
1830
|
+
The server's LangChain tools plus its `MCPServerInfo` entry.
|
|
1831
|
+
""" # noqa: DOC501 - CancelledError/KeyboardInterrupt/SystemExit are re-raised pass-throughs
|
|
1832
|
+
redact_failure_details = _config_uses_env_interpolation(server_config)
|
|
1833
|
+
|
|
1834
|
+
def _log_caught_exception(
|
|
1835
|
+
level: int,
|
|
1836
|
+
message: str,
|
|
1837
|
+
caught: BaseException,
|
|
1838
|
+
) -> None:
|
|
1839
|
+
"""Log a caught exception without exposing resolved config values."""
|
|
1840
|
+
if redact_failure_details:
|
|
1841
|
+
rendered_message = message % server_name
|
|
1842
|
+
logger.log(
|
|
1843
|
+
level,
|
|
1844
|
+
"%s (%s; details redacted because config uses environment "
|
|
1845
|
+
"interpolation)",
|
|
1846
|
+
rendered_message,
|
|
1847
|
+
caught.__class__.__name__,
|
|
1848
|
+
)
|
|
1849
|
+
else:
|
|
1850
|
+
logger.log(level, message, server_name, exc_info=caught)
|
|
1851
|
+
|
|
1852
|
+
try:
|
|
1853
|
+
async with create_session(connections[server_name]) as discover_session:
|
|
1854
|
+
await discover_session.initialize()
|
|
1855
|
+
mcp_tools = await _discover_tools(discover_session)
|
|
1856
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
1857
|
+
raise
|
|
1858
|
+
except Exception as exc: # noqa: BLE001 - isolate third-party discovery failures per server
|
|
1859
|
+
from deepagents_code.mcp_auth import (
|
|
1860
|
+
find_oauth_challenge,
|
|
1861
|
+
find_reauth_required,
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
status: MCPServerStatus
|
|
1865
|
+
try:
|
|
1866
|
+
reauth = find_reauth_required(exc)
|
|
1867
|
+
challenge_url = (
|
|
1868
|
+
find_oauth_challenge(exc)
|
|
1869
|
+
if transport in _SUPPORTED_REMOTE_TYPES
|
|
1870
|
+
else None
|
|
1871
|
+
)
|
|
1872
|
+
except Exception as classify_exc: # noqa: BLE001 - classification must not abort other servers
|
|
1873
|
+
# Classifying the failure is best-effort. If a classifier
|
|
1874
|
+
# itself raises, degrade this one server to a plain error
|
|
1875
|
+
# rather than letting the exception abort tool loading for
|
|
1876
|
+
# every remaining server.
|
|
1877
|
+
reauth = None
|
|
1878
|
+
challenge_url = None
|
|
1879
|
+
_log_caught_exception(
|
|
1880
|
+
logging.DEBUG,
|
|
1881
|
+
"MCP server '%s': failed to classify discovery error",
|
|
1882
|
+
classify_exc,
|
|
1883
|
+
)
|
|
1884
|
+
|
|
1885
|
+
if reauth is not None:
|
|
1886
|
+
# Tokens existed (we checked above) but the OAuth provider
|
|
1887
|
+
# fell back to interactive reauth — the refresh attempt
|
|
1888
|
+
# failed. Flag unauthenticated so the user is prompted to
|
|
1889
|
+
# re-login, and keep the original exception only in debug logs
|
|
1890
|
+
# so expected re-auth skips don't flood non-interactive output.
|
|
1891
|
+
status = "unauthenticated"
|
|
1892
|
+
detail = (
|
|
1893
|
+
"details redacted because config uses environment interpolation"
|
|
1894
|
+
if redact_failure_details
|
|
1895
|
+
else "the original error is in debug logs"
|
|
1896
|
+
)
|
|
1897
|
+
error = f"{reauth} (token refresh failed; {detail})"
|
|
1898
|
+
logger.warning(
|
|
1899
|
+
"MCP server '%s' skipped: %s",
|
|
1900
|
+
server_name,
|
|
1901
|
+
error,
|
|
1902
|
+
)
|
|
1903
|
+
_log_caught_exception(
|
|
1904
|
+
logging.DEBUG,
|
|
1905
|
+
"MCP server '%s' skipped: tool discovery failed",
|
|
1906
|
+
exc,
|
|
1907
|
+
)
|
|
1908
|
+
elif challenge_url is not None:
|
|
1909
|
+
# A remote server answered with a 401 OAuth challenge
|
|
1910
|
+
# (RFC 9728) that wasn't already handled as a token refresh —
|
|
1911
|
+
# typically a server not opted into OAuth in config. Surface it
|
|
1912
|
+
# as unauthenticated so the user can log in, rather than as an
|
|
1913
|
+
# opaque connection error.
|
|
1914
|
+
status = "unauthenticated"
|
|
1915
|
+
error = (
|
|
1916
|
+
f"MCP server {server_name!r} requires authentication; "
|
|
1917
|
+
f"run `dcode mcp login {server_name}`."
|
|
1918
|
+
)
|
|
1919
|
+
logger.warning(
|
|
1920
|
+
"MCP server '%s' skipped: %s",
|
|
1921
|
+
server_name,
|
|
1922
|
+
error,
|
|
1923
|
+
)
|
|
1924
|
+
_log_caught_exception(
|
|
1925
|
+
logging.DEBUG,
|
|
1926
|
+
"MCP server '%s' skipped: 401 OAuth challenge detected",
|
|
1927
|
+
exc,
|
|
1928
|
+
)
|
|
1929
|
+
else:
|
|
1930
|
+
status = "error"
|
|
1931
|
+
error = (
|
|
1932
|
+
(
|
|
1933
|
+
f"MCP server {server_name!r}: tool discovery failed "
|
|
1934
|
+
"after resolving environment variables."
|
|
1935
|
+
)
|
|
1936
|
+
if redact_failure_details
|
|
1937
|
+
else str(exc)
|
|
1938
|
+
)
|
|
1939
|
+
_log_caught_exception(
|
|
1940
|
+
logging.WARNING,
|
|
1941
|
+
"MCP server '%s' skipped: tool discovery failed",
|
|
1942
|
+
exc,
|
|
1943
|
+
)
|
|
1944
|
+
return [], MCPServerInfo(
|
|
1945
|
+
name=server_name,
|
|
1946
|
+
transport=transport,
|
|
1947
|
+
status=status,
|
|
1948
|
+
error=error,
|
|
1949
|
+
)
|
|
1950
|
+
|
|
1951
|
+
# Tool construction and filtering run after the discovery session has
|
|
1952
|
+
# closed and can still fail (schema conversion, custom tool filters).
|
|
1953
|
+
# Isolate them too so a construction error degrades this one server to
|
|
1954
|
+
# an error entry instead of aborting the whole concurrent load — the
|
|
1955
|
+
# same guarantee the discovery `try` above provides. Cancellation and
|
|
1956
|
+
# shutdown signals still propagate so the bounded runner can tear down.
|
|
1957
|
+
try:
|
|
1958
|
+
if runtime_manager is None:
|
|
1959
|
+
server_tools: list[BaseTool] = [
|
|
1960
|
+
convert_mcp_tool_to_langchain_tool(
|
|
1961
|
+
None,
|
|
1962
|
+
mcp_tool,
|
|
1963
|
+
connection=connections[server_name],
|
|
1964
|
+
server_name=server_name,
|
|
1965
|
+
tool_name_prefix=True,
|
|
1966
|
+
)
|
|
1967
|
+
for mcp_tool in mcp_tools
|
|
1968
|
+
]
|
|
1969
|
+
else:
|
|
1970
|
+
server_tools = [
|
|
1971
|
+
_build_cached_mcp_tool(
|
|
1972
|
+
mcp_tool=mcp_tool,
|
|
1973
|
+
server_name=server_name,
|
|
1974
|
+
session_manager=runtime_manager,
|
|
1975
|
+
tool_name_prefix=True,
|
|
1976
|
+
)
|
|
1977
|
+
for mcp_tool in mcp_tools
|
|
1978
|
+
]
|
|
1979
|
+
|
|
1980
|
+
server_tools = _apply_tool_filter(server_tools, server_name, server_config)
|
|
1981
|
+
|
|
1982
|
+
# Pair each tool's input_schema by its LangChain (server-prefixed)
|
|
1983
|
+
# name — the same form `server_tools` carries — so the lookup needs
|
|
1984
|
+
# no string surgery and stays correct if `tool_name_prefix` ever
|
|
1985
|
+
# changes. Deep-copy the raw dict because `MCPToolInfo` is `frozen`
|
|
1986
|
+
# but Python's `frozen=True` does not freeze nested mutables; a
|
|
1987
|
+
# shared reference would let one holder mutate every other's view.
|
|
1988
|
+
schemas: dict[str, dict[str, Any] | None] = {}
|
|
1989
|
+
for mcp_tool in mcp_tools:
|
|
1990
|
+
tool_name = getattr(mcp_tool, "name", "")
|
|
1991
|
+
try:
|
|
1992
|
+
raw_schema = getattr(mcp_tool, "inputSchema", None)
|
|
1993
|
+
schema_copy = (
|
|
1994
|
+
copy.deepcopy(raw_schema) if raw_schema is not None else None
|
|
1995
|
+
)
|
|
1996
|
+
except (AttributeError, TypeError, RecursionError) as exc:
|
|
1997
|
+
logger.warning(
|
|
1998
|
+
"MCP tool %r on server %r: inputSchema access raised "
|
|
1999
|
+
"%s: %s; rendering with no parameters",
|
|
2000
|
+
tool_name,
|
|
2001
|
+
server_name,
|
|
2002
|
+
exc.__class__.__name__,
|
|
2003
|
+
exc,
|
|
2004
|
+
)
|
|
2005
|
+
schema_copy = None
|
|
2006
|
+
lc_name = f"{server_name}_{tool_name}"
|
|
2007
|
+
schemas[lc_name] = schema_copy
|
|
2008
|
+
|
|
2009
|
+
tool_infos: list[MCPToolInfo] = []
|
|
2010
|
+
for tool in server_tools:
|
|
2011
|
+
schema = schemas.get(tool.name)
|
|
2012
|
+
if schema is None and schemas:
|
|
2013
|
+
logger.debug(
|
|
2014
|
+
"MCP tool %r on server %r: no schema matched in lookup "
|
|
2015
|
+
"(available keys: %s); rendering with no parameters",
|
|
2016
|
+
tool.name,
|
|
2017
|
+
server_name,
|
|
2018
|
+
list(schemas.keys())[:5],
|
|
2019
|
+
)
|
|
2020
|
+
tool_infos.append(
|
|
2021
|
+
MCPToolInfo(
|
|
2022
|
+
name=tool.name,
|
|
2023
|
+
description=tool.description or "",
|
|
2024
|
+
input_schema=schema,
|
|
2025
|
+
),
|
|
2026
|
+
)
|
|
2027
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
2028
|
+
raise
|
|
2029
|
+
except Exception as exc: # noqa: BLE001 - isolate third-party tool conversion failures per server
|
|
2030
|
+
error = (
|
|
2031
|
+
(
|
|
2032
|
+
f"MCP server {server_name!r}: tool construction failed "
|
|
2033
|
+
"after resolving environment variables."
|
|
2034
|
+
)
|
|
2035
|
+
if redact_failure_details
|
|
2036
|
+
else str(exc)
|
|
2037
|
+
)
|
|
2038
|
+
_log_caught_exception(
|
|
2039
|
+
logging.WARNING,
|
|
2040
|
+
"MCP server '%s' skipped: tool construction failed",
|
|
2041
|
+
exc,
|
|
2042
|
+
)
|
|
2043
|
+
return [], MCPServerInfo(
|
|
2044
|
+
name=server_name,
|
|
2045
|
+
transport=transport,
|
|
2046
|
+
status="error",
|
|
2047
|
+
error=error,
|
|
2048
|
+
)
|
|
2049
|
+
|
|
2050
|
+
return server_tools, MCPServerInfo(
|
|
2051
|
+
name=server_name,
|
|
2052
|
+
transport=transport,
|
|
2053
|
+
tools=tuple(tool_infos),
|
|
2054
|
+
)
|
|
2055
|
+
|
|
2056
|
+
# Discovery also runs concurrently (bounded) across the servers that
|
|
2057
|
+
# survived preflight. Because `_gather_bounded` returns results in
|
|
2058
|
+
# submission order and skipped servers are folded back in below by
|
|
2059
|
+
# iterating `server_items` in config order, `server_infos` stays in config
|
|
2060
|
+
# order and the returned tools stay sorted by tool name — regardless of
|
|
2061
|
+
# which server's probe finished first.
|
|
2062
|
+
discover_items = [
|
|
2063
|
+
(server_name, server_config, transports[server_name])
|
|
2064
|
+
for server_name, server_config in server_items
|
|
2065
|
+
if server_name not in skipped
|
|
2066
|
+
]
|
|
2067
|
+
discovery_results = await _gather_bounded(
|
|
2068
|
+
[
|
|
2069
|
+
functools.partial(_discover_server, name, cfg, transport)
|
|
2070
|
+
for name, cfg, transport in discover_items
|
|
2071
|
+
],
|
|
2072
|
+
limit=_MCP_LOAD_CONCURRENCY,
|
|
2073
|
+
)
|
|
2074
|
+
discovered: dict[str, tuple[list[BaseTool], MCPServerInfo]] = {
|
|
2075
|
+
server_name: result
|
|
2076
|
+
for (server_name, _cfg, _transport), result in zip(
|
|
2077
|
+
discover_items, discovery_results, strict=True
|
|
2078
|
+
)
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
all_tools: list[BaseTool] = []
|
|
2082
|
+
server_infos: list[MCPServerInfo] = []
|
|
2083
|
+
for server_name, _server_config in server_items:
|
|
2084
|
+
if server_name in skipped:
|
|
2085
|
+
status, error = skipped[server_name]
|
|
2086
|
+
server_infos.append(
|
|
2087
|
+
MCPServerInfo(
|
|
2088
|
+
name=server_name,
|
|
2089
|
+
transport=transports[server_name],
|
|
2090
|
+
status=status,
|
|
2091
|
+
error=error,
|
|
2092
|
+
),
|
|
2093
|
+
)
|
|
2094
|
+
continue
|
|
2095
|
+
server_tools, server_info = discovered[server_name]
|
|
2096
|
+
all_tools.extend(server_tools)
|
|
2097
|
+
server_infos.append(server_info)
|
|
2098
|
+
|
|
2099
|
+
all_tools.sort(key=lambda tool: tool.name)
|
|
2100
|
+
return all_tools, None if stateless else runtime_manager, server_infos
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
async def get_mcp_tools(
|
|
2104
|
+
config_path: str,
|
|
2105
|
+
) -> tuple[list[BaseTool], MCPSessionManager | None, list[MCPServerInfo]]:
|
|
2106
|
+
"""Load MCP tools from a configuration file.
|
|
2107
|
+
|
|
2108
|
+
Args:
|
|
2109
|
+
config_path: Path to an MCP config file.
|
|
2110
|
+
|
|
2111
|
+
Returns:
|
|
2112
|
+
Tuple of `(tools_list, runtime_session_manager, server_infos)`.
|
|
2113
|
+
|
|
2114
|
+
Raises:
|
|
2115
|
+
FileNotFoundError: If `config_path` doesn't exist.
|
|
2116
|
+
json.JSONDecodeError: If the config file contains invalid JSON.
|
|
2117
|
+
TypeError: If config fields have wrong types.
|
|
2118
|
+
ValueError: If the config is missing required fields.
|
|
2119
|
+
""" # noqa: DOC502 - surfaced via `load_mcp_config`
|
|
2120
|
+
config = load_mcp_config(config_path)
|
|
2121
|
+
return await _load_tools_from_config(config)
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _log_skipped_project_servers(
|
|
2125
|
+
dropped: list[tuple[str, str, str]],
|
|
2126
|
+
*,
|
|
2127
|
+
trust_project_mcp: bool | None,
|
|
2128
|
+
config_trusted: bool,
|
|
2129
|
+
) -> None:
|
|
2130
|
+
"""Log project MCP servers that were dropped, explaining why.
|
|
2131
|
+
|
|
2132
|
+
Split out so the trust/drop loop stays readable. The message distinguishes an
|
|
2133
|
+
explicit reject on an otherwise-trusted config from the untrusted-drop cases,
|
|
2134
|
+
which themselves differ by whether trust was declined outright
|
|
2135
|
+
(`--trust-project-mcp` off) or merely not yet granted.
|
|
2136
|
+
|
|
2137
|
+
Args:
|
|
2138
|
+
dropped: `(name, kind, summary)` tuples for each skipped server.
|
|
2139
|
+
trust_project_mcp: The caller's tri-state trust flag.
|
|
2140
|
+
config_trusted: Whether the project config was otherwise trusted (so the
|
|
2141
|
+
only reason to drop is an explicit user-level deny entry).
|
|
2142
|
+
"""
|
|
2143
|
+
skipped_list = "\n".join(
|
|
2144
|
+
f"- {name} [{kind}]: {summary}" for name, kind, summary in dropped
|
|
2145
|
+
)
|
|
2146
|
+
if config_trusted:
|
|
2147
|
+
logger.warning(
|
|
2148
|
+
"Skipped project MCP servers rejected by user config "
|
|
2149
|
+
"(disabled_project_servers):\n%s",
|
|
2150
|
+
skipped_list,
|
|
2151
|
+
)
|
|
2152
|
+
elif trust_project_mcp is False:
|
|
2153
|
+
logger.warning(
|
|
2154
|
+
"Skipped untrusted project MCP servers:\n%s",
|
|
2155
|
+
skipped_list,
|
|
2156
|
+
)
|
|
2157
|
+
else:
|
|
2158
|
+
logger.warning(
|
|
2159
|
+
"Skipped untrusted project MCP servers "
|
|
2160
|
+
"(config changed or not yet approved):\n%s",
|
|
2161
|
+
skipped_list,
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
async def resolve_and_load_mcp_tools(
|
|
2166
|
+
*,
|
|
2167
|
+
explicit_config_path: str | None = None,
|
|
2168
|
+
no_mcp: bool = False,
|
|
2169
|
+
trust_project_mcp: bool | None = None,
|
|
2170
|
+
project_context: ProjectContext | None = None,
|
|
2171
|
+
stateless: bool = False,
|
|
2172
|
+
session_manager: MCPSessionManager | None = None,
|
|
2173
|
+
) -> tuple[list[BaseTool], MCPSessionManager | None, list[MCPServerInfo]]:
|
|
2174
|
+
"""Resolve MCP config and load tools.
|
|
2175
|
+
|
|
2176
|
+
Auto-discovers configs from standard locations and merges them. When
|
|
2177
|
+
`explicit_config_path` is provided it is added as the highest-precedence
|
|
2178
|
+
source and errors in that file are fatal.
|
|
2179
|
+
|
|
2180
|
+
Args:
|
|
2181
|
+
explicit_config_path: Extra config file to layer on top of
|
|
2182
|
+
auto-discovered configs.
|
|
2183
|
+
no_mcp: If `True`, disable all MCP loading.
|
|
2184
|
+
trust_project_mcp: Controls project-level server trust.
|
|
2185
|
+
|
|
2186
|
+
Applies to stdio and remote (http/sse) servers alike — remote entries
|
|
2187
|
+
are gated too because an attacker-controlled `.mcp.json` can SSRF or
|
|
2188
|
+
exfiltrate `${VAR}` headers during the discovery preflight.
|
|
2189
|
+
|
|
2190
|
+
- `True`: always trust project configs (all servers).
|
|
2191
|
+
- `False`: drop all project servers (stdio and remote).
|
|
2192
|
+
- `None`: consult the persistent trust store — trusted configs
|
|
2193
|
+
load fully; all servers from untrusted project configs are
|
|
2194
|
+
dropped.
|
|
2195
|
+
|
|
2196
|
+
Regardless of this flag, the user-level allow/deny lists
|
|
2197
|
+
(`[mcp].enabled_project_servers` /`disabled_project_servers` and
|
|
2198
|
+
their env equivalents, via `load_mcp_server_trust_lists`) are
|
|
2199
|
+
applied: named servers load from an otherwise-untrusted config,
|
|
2200
|
+
and explicitly denied servers are dropped even from a trusted one.
|
|
2201
|
+
project_context: Explicit project path context for config discovery
|
|
2202
|
+
and trust resolution.
|
|
2203
|
+
stateless: When `True`, do not return an owned runtime session manager.
|
|
2204
|
+
session_manager: Optional externally owned runtime session manager.
|
|
2205
|
+
|
|
2206
|
+
Returns:
|
|
2207
|
+
Tuple of `(tools_list, session_manager, server_infos)`.
|
|
2208
|
+
|
|
2209
|
+
Raises:
|
|
2210
|
+
FileNotFoundError: If `explicit_config_path` was provided and points
|
|
2211
|
+
at a missing file.
|
|
2212
|
+
json.JSONDecodeError: If `explicit_config_path` contains invalid
|
|
2213
|
+
JSON.
|
|
2214
|
+
TypeError: If `explicit_config_path` contents have wrong field
|
|
2215
|
+
types.
|
|
2216
|
+
ValueError: If `explicit_config_path` is missing required fields
|
|
2217
|
+
or declares an unsupported transport.
|
|
2218
|
+
RuntimeError: If the merged MCP config is malformed. (`${VAR}`
|
|
2219
|
+
config interpolation is deferred to activation inside
|
|
2220
|
+
`_load_tools_from_config`, which captures such failures into the
|
|
2221
|
+
returned `server_infos` rather than raising here.)
|
|
2222
|
+
""" # noqa: DOC502 - FileNotFoundError / JSONDecodeError / TypeError / ValueError surface via `load_mcp_config`
|
|
2223
|
+
if no_mcp:
|
|
2224
|
+
return [], None, []
|
|
2225
|
+
|
|
2226
|
+
config_load_errors: list[tuple[Path, str]] = []
|
|
2227
|
+
|
|
2228
|
+
try:
|
|
2229
|
+
config_paths = discover_mcp_configs(project_context=project_context)
|
|
2230
|
+
except (OSError, RuntimeError) as exc:
|
|
2231
|
+
logger.warning("MCP config auto-discovery failed", exc_info=True)
|
|
2232
|
+
config_paths = []
|
|
2233
|
+
config_load_errors.append((Path("<discovery>"), str(exc)))
|
|
2234
|
+
|
|
2235
|
+
user_configs, project_configs = classify_discovered_configs(config_paths)
|
|
2236
|
+
configs: list[dict[str, Any]] = []
|
|
2237
|
+
|
|
2238
|
+
for path in user_configs:
|
|
2239
|
+
config, error = load_mcp_config_with_error(path)
|
|
2240
|
+
if error is not None:
|
|
2241
|
+
config_load_errors.append((path, error))
|
|
2242
|
+
if config is not None:
|
|
2243
|
+
configs.append(config)
|
|
2244
|
+
|
|
2245
|
+
project_trusted: bool | None = None
|
|
2246
|
+
trust_lists: McpServerTrustLists | None = None
|
|
2247
|
+
for path in project_configs:
|
|
2248
|
+
config, error = _load_mcp_config_top_level_with_error(path)
|
|
2249
|
+
if error is not None:
|
|
2250
|
+
config_load_errors.append((path, error))
|
|
2251
|
+
if config is None:
|
|
2252
|
+
continue
|
|
2253
|
+
|
|
2254
|
+
project_servers = extract_project_server_summaries(config)
|
|
2255
|
+
if not project_servers:
|
|
2256
|
+
# No dict servers yielded a summary, so every entry is malformed.
|
|
2257
|
+
# Re-validate the already-loaded config (no second file read) to
|
|
2258
|
+
# surface a precise per-server error; this always fails today, but
|
|
2259
|
+
# the append path is kept so a future validator that accepts such a
|
|
2260
|
+
# shape would still load it.
|
|
2261
|
+
try:
|
|
2262
|
+
_validate_mcp_config_servers(config)
|
|
2263
|
+
except (ValueError, TypeError, RuntimeError) as exc:
|
|
2264
|
+
config_load_errors.append((path, str(exc)))
|
|
2265
|
+
else:
|
|
2266
|
+
configs.append(config)
|
|
2267
|
+
continue
|
|
2268
|
+
|
|
2269
|
+
# Whether the config as a whole is trusted (flag/env/fingerprint). This
|
|
2270
|
+
# governs the default for un-listed servers; the user-level allow/deny
|
|
2271
|
+
# lists below refine it per server.
|
|
2272
|
+
if trust_project_mcp is True:
|
|
2273
|
+
config_trusted = True
|
|
2274
|
+
elif trust_project_mcp is False:
|
|
2275
|
+
config_trusted = False
|
|
2276
|
+
else:
|
|
2277
|
+
if project_trusted is None:
|
|
2278
|
+
from deepagents_code.mcp_trust import (
|
|
2279
|
+
compute_config_fingerprint,
|
|
2280
|
+
is_project_mcp_trusted,
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
project_root = str(
|
|
2284
|
+
_resolve_project_config_base(project_context).resolve()
|
|
2285
|
+
)
|
|
2286
|
+
fingerprint = compute_config_fingerprint(project_configs)
|
|
2287
|
+
project_trusted = is_project_mcp_trusted(project_root, fingerprint)
|
|
2288
|
+
config_trusted = project_trusted
|
|
2289
|
+
|
|
2290
|
+
# The allow/deny lists are sourced only from the user's own config (home
|
|
2291
|
+
# config.toml + env) — never from the repo — so a committed .mcp.json
|
|
2292
|
+
# cannot self-approve. Loaded lazily and reused across project configs.
|
|
2293
|
+
if trust_lists is None:
|
|
2294
|
+
from deepagents_code.model_config import (
|
|
2295
|
+
DEFAULT_CONFIG_PATH,
|
|
2296
|
+
load_mcp_server_trust_lists,
|
|
2297
|
+
)
|
|
2298
|
+
|
|
2299
|
+
trust_lists = load_mcp_server_trust_lists()
|
|
2300
|
+
if trust_lists.read_error is not None:
|
|
2301
|
+
# Surface the read failure as a visible config error (a bare
|
|
2302
|
+
# logger.warning has no handler outside debug mode).
|
|
2303
|
+
config_load_errors.append((DEFAULT_CONFIG_PATH, trust_lists.read_error))
|
|
2304
|
+
|
|
2305
|
+
if trust_lists.load_failed:
|
|
2306
|
+
# Fail closed: the user's allow/deny policy could not be read, so do
|
|
2307
|
+
# not honor whole-config trust — otherwise a server the user meant to
|
|
2308
|
+
# deny would load. Names explicitly enabled via a readable source
|
|
2309
|
+
# (shell env) still survive the filter below.
|
|
2310
|
+
config_trusted = False
|
|
2311
|
+
|
|
2312
|
+
# Keep only servers that survive the trust decision. Dropping the rest
|
|
2313
|
+
# here (rather than loading all or none) preserves the SSRF/header-
|
|
2314
|
+
# exfiltration gate: a non-allowlisted remote entry from an attacker-
|
|
2315
|
+
# controlled .mcp.json never reaches the preflight HEAD probe or the
|
|
2316
|
+
# `${VAR}` config interpolation during the discovery handshake.
|
|
2317
|
+
servers = config["mcpServers"]
|
|
2318
|
+
kept: dict[str, Any] = {}
|
|
2319
|
+
for name, server in servers.items():
|
|
2320
|
+
if name in trust_lists.disabled:
|
|
2321
|
+
# Explicit reject always wins, even for a trusted config.
|
|
2322
|
+
continue
|
|
2323
|
+
if config_trusted or name in trust_lists.enabled:
|
|
2324
|
+
kept[name] = server
|
|
2325
|
+
if kept:
|
|
2326
|
+
filtered = {**config, "mcpServers": kept}
|
|
2327
|
+
try:
|
|
2328
|
+
_validate_mcp_config_servers(filtered)
|
|
2329
|
+
except (ValueError, TypeError, RuntimeError) as exc:
|
|
2330
|
+
# The whole filtered config is dropped, so name the kept
|
|
2331
|
+
# (trusted/allowlisted) servers that will NOT load — otherwise
|
|
2332
|
+
# they vanish silently (the skip-log below only covers servers
|
|
2333
|
+
# dropped by the trust decision, not by this validation failure).
|
|
2334
|
+
logger.warning(
|
|
2335
|
+
"Skipping invalid MCP config %s after project trust "
|
|
2336
|
+
"filtering; these trusted/allowlisted servers will not "
|
|
2337
|
+
"load: %s (%s)",
|
|
2338
|
+
path,
|
|
2339
|
+
", ".join(kept),
|
|
2340
|
+
exc,
|
|
2341
|
+
)
|
|
2342
|
+
config_load_errors.append((path, str(exc)))
|
|
2343
|
+
else:
|
|
2344
|
+
configs.append(filtered)
|
|
2345
|
+
|
|
2346
|
+
# Servers dropped by the trust *decision* (disabled, or not allowlisted
|
|
2347
|
+
# in an untrusted config). A validation failure above is reported
|
|
2348
|
+
# separately, so those kept-but-unloaded names are intentionally not
|
|
2349
|
+
# re-listed here with a trust-based reason.
|
|
2350
|
+
dropped = [
|
|
2351
|
+
(name, kind, summary)
|
|
2352
|
+
for name, kind, summary in project_servers
|
|
2353
|
+
if name not in kept
|
|
2354
|
+
]
|
|
2355
|
+
if dropped:
|
|
2356
|
+
_log_skipped_project_servers(
|
|
2357
|
+
dropped,
|
|
2358
|
+
trust_project_mcp=trust_project_mcp,
|
|
2359
|
+
config_trusted=config_trusted,
|
|
2360
|
+
)
|
|
2361
|
+
|
|
2362
|
+
if explicit_config_path:
|
|
2363
|
+
config_path = (
|
|
2364
|
+
str(project_context.resolve_user_path(explicit_config_path))
|
|
2365
|
+
if project_context is not None
|
|
2366
|
+
else explicit_config_path
|
|
2367
|
+
)
|
|
2368
|
+
configs.append(load_mcp_config(config_path))
|
|
2369
|
+
|
|
2370
|
+
def _bad_config_infos() -> list[MCPServerInfo]:
|
|
2371
|
+
return [
|
|
2372
|
+
MCPServerInfo(
|
|
2373
|
+
name=f"<config:{path.name}>",
|
|
2374
|
+
transport="config",
|
|
2375
|
+
status="error",
|
|
2376
|
+
error=f"{path}: {error}",
|
|
2377
|
+
)
|
|
2378
|
+
for path, error in config_load_errors
|
|
2379
|
+
]
|
|
2380
|
+
|
|
2381
|
+
if not configs:
|
|
2382
|
+
return [], None, _bad_config_infos()
|
|
2383
|
+
|
|
2384
|
+
merged = merge_mcp_configs(configs)
|
|
2385
|
+
if not merged.get("mcpServers"):
|
|
2386
|
+
return [], None, _bad_config_infos()
|
|
2387
|
+
|
|
2388
|
+
from deepagents_code.mcp_disabled import get_disabled_servers
|
|
2389
|
+
|
|
2390
|
+
disabled_names = get_disabled_servers()
|
|
2391
|
+
disabled_infos: list[MCPServerInfo] = []
|
|
2392
|
+
if disabled_names:
|
|
2393
|
+
active: dict[str, Any] = {}
|
|
2394
|
+
for server_name, server_config in merged["mcpServers"].items():
|
|
2395
|
+
if server_name in disabled_names:
|
|
2396
|
+
disabled_infos.append(
|
|
2397
|
+
MCPServerInfo(
|
|
2398
|
+
name=server_name,
|
|
2399
|
+
transport=_resolve_server_type(server_config)
|
|
2400
|
+
if isinstance(server_config, dict)
|
|
2401
|
+
else "unknown",
|
|
2402
|
+
status="disabled",
|
|
2403
|
+
error="Disabled by user (F2 to re-enable).",
|
|
2404
|
+
),
|
|
2405
|
+
)
|
|
2406
|
+
else:
|
|
2407
|
+
active[server_name] = server_config
|
|
2408
|
+
merged = {"mcpServers": active}
|
|
2409
|
+
|
|
2410
|
+
if not merged.get("mcpServers"):
|
|
2411
|
+
return [], None, disabled_infos + _bad_config_infos()
|
|
2412
|
+
|
|
2413
|
+
try:
|
|
2414
|
+
for server_name, server_config in merged["mcpServers"].items():
|
|
2415
|
+
_validate_server_config(server_name, server_config)
|
|
2416
|
+
except (TypeError, ValueError, RuntimeError) as exc:
|
|
2417
|
+
msg = f"Invalid MCP server configuration: {exc}"
|
|
2418
|
+
raise RuntimeError(msg) from exc
|
|
2419
|
+
|
|
2420
|
+
tools, manager, server_infos = await _load_tools_from_config(
|
|
2421
|
+
merged,
|
|
2422
|
+
stateless=stateless,
|
|
2423
|
+
session_manager=session_manager,
|
|
2424
|
+
)
|
|
2425
|
+
server_infos.extend(disabled_infos)
|
|
2426
|
+
server_infos.extend(_bad_config_infos())
|
|
2427
|
+
return tools, manager, server_infos
|