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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. 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