|
12 | 12 | import contextvars |
13 | 13 | import json |
14 | 14 | import logging |
| 15 | +import os |
| 16 | +import re |
15 | 17 | import sys |
16 | 18 | import threading |
17 | 19 | import uuid |
18 | 20 | from asyncio import AbstractEventLoop |
19 | 21 | from collections.abc import Callable, Coroutine, Sequence |
20 | 22 | from concurrent import futures |
21 | 23 | from datetime import timedelta |
| 24 | +from pathlib import Path |
22 | 25 | from re import Pattern |
23 | 26 | from types import TracebackType |
24 | 27 | from typing import Any, TypeVar, cast |
25 | 28 |
|
26 | 29 | import anyio |
27 | | -from mcp import ClientSession, ListToolsResult |
| 30 | +from mcp import ClientSession, ListToolsResult, StdioServerParameters, stdio_client |
28 | 31 | from mcp.client.session import ElicitationFnT |
| 32 | +from mcp.client.sse import sse_client |
| 33 | +from mcp.client.streamable_http import streamablehttp_client |
29 | 34 | from mcp.shared.exceptions import McpError |
30 | 35 | from mcp.shared.session import ProgressFnT |
31 | 36 | from mcp.types import ( |
@@ -79,6 +84,27 @@ class ToolFilters(TypedDict, total=False): |
79 | 84 | rejected: list[_ToolMatcher] |
80 | 85 |
|
81 | 86 |
|
| 87 | +class MCPServerConfig(TypedDict, total=False): |
| 88 | + """Schema for a single MCP server entry in a load_servers config. |
| 89 | +
|
| 90 | + Provide either 'command' (stdio) or 'url' (streamable-http/sse), not both. When 'transport' is |
| 91 | + omitted it is auto-detected from the fields present. String values support '${VAR}' / |
| 92 | + '${env:VAR}' interpolation, and '~' in 'command' and 'cwd' is expanded to the home directory. |
| 93 | + """ |
| 94 | + |
| 95 | + command: str |
| 96 | + args: list[str] |
| 97 | + env: dict[str, str] |
| 98 | + cwd: str |
| 99 | + url: str |
| 100 | + headers: dict[str, str] |
| 101 | + transport: str |
| 102 | + disabled: bool |
| 103 | + prefix: str |
| 104 | + tool_filters: ToolFilters |
| 105 | + startup_timeout: int |
| 106 | + |
| 107 | + |
82 | 108 | MIME_TO_FORMAT: dict[str, ImageFormat] = { |
83 | 109 | "image/jpeg": "jpeg", |
84 | 110 | "image/jpg": "jpeg", |
@@ -114,6 +140,53 @@ class MCPClient(ToolProvider): |
114 | 140 | from MCP tools, it will be returned as the last item in the content array of the ToolResult. |
115 | 141 | """ |
116 | 142 |
|
| 143 | + @classmethod |
| 144 | + def load_servers(cls, config: "str | dict[str, Any]") -> "list[MCPClient]": |
| 145 | + """Create MCPClient instances from an ``mcpServers`` JSON config (file path or mapping). |
| 146 | +
|
| 147 | + Returns one client per enabled server. Accepts either a flat mapping of server name to |
| 148 | + config, or that mapping nested under an ``mcpServers`` key. Servers marked |
| 149 | + ``"disabled": true`` are skipped. |
| 150 | +
|
| 151 | + Transport is auto-detected from the fields present: ``command`` selects stdio and ``url`` |
| 152 | + selects streamable-http. Set ``transport`` explicitly (``"stdio"``, ``"sse"``, or |
| 153 | + ``"streamable-http"``) to override. String values support ``${VAR}`` / ``${env:VAR}`` |
| 154 | + interpolation against the process environment, and ``~`` in ``command`` and ``cwd`` is |
| 155 | + expanded to the user's home directory. |
| 156 | +
|
| 157 | + Args: |
| 158 | + config: A file path (with optional ``file://`` prefix) to a JSON config, or a |
| 159 | + dictionary mapping server names to configs (optionally under an ``mcpServers`` key). |
| 160 | +
|
| 161 | + Returns: |
| 162 | + One MCPClient per enabled server, ready to pass to ``Agent(tools=...)``. |
| 163 | +
|
| 164 | + Raises: |
| 165 | + FileNotFoundError: If the config file does not exist. |
| 166 | + json.JSONDecodeError: If the config file contains invalid JSON. |
| 167 | + ValueError: If the config shape is invalid, a server entry is not a mapping, a server |
| 168 | + config is invalid, or a referenced environment variable is not set. |
| 169 | + """ |
| 170 | + servers = _load_servers_mapping(config) |
| 171 | + |
| 172 | + clients: list[MCPClient] = [] |
| 173 | + for name, server in servers.items(): |
| 174 | + # A non-dict entry is a malformed-config error and always raises, unlike per-server failures. |
| 175 | + if not isinstance(server, dict): |
| 176 | + raise ValueError(f"server '{name}' configuration must be a dictionary, got {type(server).__name__}") |
| 177 | + unknown_keys = sorted(server.keys() - MCPServerConfig.__annotations__.keys()) |
| 178 | + if unknown_keys: |
| 179 | + logger.warning( |
| 180 | + "server_name=<%s>, unknown_keys=<%s> | ignoring unrecognized MCP config keys", name, unknown_keys |
| 181 | + ) |
| 182 | + if server.get("disabled", False): |
| 183 | + logger.debug("server_name=<%s> | skipping disabled MCP server", name) |
| 184 | + continue |
| 185 | + clients.append(_build_client_from_config(name, server)) |
| 186 | + |
| 187 | + logger.debug("loaded_servers=<%d> | created MCP clients from config", len(clients)) |
| 188 | + return clients |
| 189 | + |
117 | 190 | def __init__( |
118 | 191 | self, |
119 | 192 | transport_callable: Callable[[], MCPTransport], |
@@ -1259,3 +1332,145 @@ async def _poll_until_terminal() -> GetTaskResult | None: |
1259 | 1332 | final_status.status, |
1260 | 1333 | ) |
1261 | 1334 | return self._create_task_error_result(f"Unexpected task status: {final_status.status}") |
| 1335 | + |
| 1336 | + |
| 1337 | +# Matches ${VAR} and ${env:VAR} where VAR is a valid environment variable identifier. |
| 1338 | +_ENV_VAR_PATTERN = re.compile(r"\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}") |
| 1339 | + |
| 1340 | + |
| 1341 | +def _load_servers_mapping(config: str | dict[str, Any]) -> dict[str, Any]: |
| 1342 | + """Resolve the load_servers input into a mapping of server name to server config. |
| 1343 | +
|
| 1344 | + Reads and parses the file when config is a path, then unwraps the optional ``mcpServers`` key |
| 1345 | + so both wrapped and flat shapes are accepted. |
| 1346 | + """ |
| 1347 | + if isinstance(config, str): |
| 1348 | + file_path = config[len("file://") :] if config.startswith("file://") else config |
| 1349 | + path = Path(file_path).expanduser() |
| 1350 | + if not path.exists(): |
| 1351 | + raise FileNotFoundError(f"MCP configuration file not found: {file_path}") |
| 1352 | + config_dict = json.loads(path.read_text()) |
| 1353 | + elif isinstance(config, dict): |
| 1354 | + config_dict = config |
| 1355 | + else: |
| 1356 | + raise ValueError("config must be a file path string or a dictionary") |
| 1357 | + |
| 1358 | + servers = config_dict.get("mcpServers", config_dict) if isinstance(config_dict, dict) else None |
| 1359 | + if not isinstance(servers, dict): |
| 1360 | + raise ValueError( |
| 1361 | + 'MCP config must be a JSON object mapping server names to configs, e.g. {"my-server": {"command": "node"}}' |
| 1362 | + ) |
| 1363 | + return servers |
| 1364 | + |
| 1365 | + |
| 1366 | +def _build_client_from_config(name: str, server: dict[str, Any]) -> MCPClient: |
| 1367 | + """Build a single MCPClient from one server entry. |
| 1368 | +
|
| 1369 | + Interpolates ``${VAR}`` references first so secrets can live in the environment, then detects |
| 1370 | + the transport and constructs the matching transport callable. |
| 1371 | + """ |
| 1372 | + server = cast(dict[str, Any], _interpolate_env_vars(server)) |
| 1373 | + |
| 1374 | + if server.get("command") and server.get("url") and not server.get("transport"): |
| 1375 | + raise ValueError(f"server '{name}' has both 'command' and 'url' — set 'transport' explicitly or remove one") |
| 1376 | + |
| 1377 | + transport = server.get("transport") |
| 1378 | + if transport is None: |
| 1379 | + transport = "stdio" if server.get("command") else "streamable-http" if server.get("url") else None |
| 1380 | + if transport is None: |
| 1381 | + raise ValueError(f"server '{name}' must include either 'command' (stdio) or 'url' (http)") |
| 1382 | + |
| 1383 | + transport_callable = _config_transport_callable(name, transport, server) |
| 1384 | + |
| 1385 | + logger.debug("server_name=<%s>, transport=<%s> | creating MCP client from config", name, transport) |
| 1386 | + return MCPClient( |
| 1387 | + transport_callable, |
| 1388 | + startup_timeout=server.get("startup_timeout", 30), |
| 1389 | + tool_filters=_parse_config_tool_filters(name, server.get("tool_filters")), |
| 1390 | + prefix=server.get("prefix"), |
| 1391 | + ) |
| 1392 | + |
| 1393 | + |
| 1394 | +def _config_transport_callable(name: str, transport: str, server: dict[str, Any]) -> Callable[[], MCPTransport]: |
| 1395 | + """Return a zero-arg callable that opens the transport for the given server entry.""" |
| 1396 | + match transport: |
| 1397 | + case "stdio": |
| 1398 | + command = server.get("command") |
| 1399 | + if not command: |
| 1400 | + raise ValueError(f"server '{name}': stdio transport requires 'command'") |
| 1401 | + params = StdioServerParameters( |
| 1402 | + command=os.path.expanduser(command), |
| 1403 | + args=server.get("args", []), |
| 1404 | + env=server.get("env"), |
| 1405 | + cwd=os.path.expanduser(server["cwd"]) if server.get("cwd") else None, |
| 1406 | + ) |
| 1407 | + return lambda: stdio_client(params) |
| 1408 | + |
| 1409 | + case "streamable-http": |
| 1410 | + url = server.get("url") |
| 1411 | + if not url: |
| 1412 | + raise ValueError(f"server '{name}': streamable-http transport requires 'url'") |
| 1413 | + headers = server.get("headers") |
| 1414 | + return lambda: streamablehttp_client(url=cast(str, url), headers=headers) |
| 1415 | + |
| 1416 | + case "sse": |
| 1417 | + url = server.get("url") |
| 1418 | + if not url: |
| 1419 | + raise ValueError(f"server '{name}': sse transport requires 'url'") |
| 1420 | + headers = server.get("headers") |
| 1421 | + return lambda: sse_client(url=cast(str, url), headers=headers) |
| 1422 | + |
| 1423 | + case _: |
| 1424 | + raise ValueError(f"server '{name}': unsupported transport type '{transport}'") |
| 1425 | + |
| 1426 | + |
| 1427 | +def _parse_config_tool_filters(name: str, config: dict[str, Any] | None) -> ToolFilters | None: |
| 1428 | + """Compile a tool-filter config into a ToolFilters mapping of regex patterns. |
| 1429 | +
|
| 1430 | + Patterns match the server-side (unprefixed) tool name; any ``prefix`` is applied afterwards. |
| 1431 | + """ |
| 1432 | + if not config: |
| 1433 | + return None |
| 1434 | + |
| 1435 | + result: ToolFilters = {} |
| 1436 | + if config.get("allowed") is not None: |
| 1437 | + result["allowed"] = _compile_filter_patterns(name, "allowed", config["allowed"]) |
| 1438 | + if config.get("rejected") is not None: |
| 1439 | + result["rejected"] = _compile_filter_patterns(name, "rejected", config["rejected"]) |
| 1440 | + |
| 1441 | + return result or None |
| 1442 | + |
| 1443 | + |
| 1444 | +def _compile_filter_patterns(name: str, key: str, patterns: list[str]) -> list[_ToolMatcher]: |
| 1445 | + """Compile a list of regex strings into patterns, raising a clear error on an invalid one.""" |
| 1446 | + compiled: list[_ToolMatcher] = [] |
| 1447 | + for pattern in patterns: |
| 1448 | + try: |
| 1449 | + compiled.append(re.compile(pattern)) |
| 1450 | + except re.error as e: |
| 1451 | + raise ValueError(f"server '{name}': invalid regex in tool_filters.{key}: '{pattern}': {e}") from e |
| 1452 | + return compiled |
| 1453 | + |
| 1454 | + |
| 1455 | +def _interpolate_env_vars(value: Any) -> Any: |
| 1456 | + """Recursively replace ``${VAR}`` / ``${env:VAR}`` references in strings with env values. |
| 1457 | +
|
| 1458 | + Strings nested inside lists and dicts are interpolated; other types pass through unchanged. |
| 1459 | +
|
| 1460 | + Raises: |
| 1461 | + ValueError: If a referenced environment variable is not set. |
| 1462 | + """ |
| 1463 | + if isinstance(value, str): |
| 1464 | + |
| 1465 | + def _replace(match: re.Match[str]) -> str: |
| 1466 | + var = match.group(1) |
| 1467 | + if var not in os.environ: |
| 1468 | + raise ValueError(f"environment variable '{var}' is not set") |
| 1469 | + return os.environ[var] |
| 1470 | + |
| 1471 | + return _ENV_VAR_PATTERN.sub(_replace, value) |
| 1472 | + if isinstance(value, list): |
| 1473 | + return [_interpolate_env_vars(item) for item in value] |
| 1474 | + if isinstance(value, dict): |
| 1475 | + return {key: _interpolate_env_vars(item) for key, item in value.items()} |
| 1476 | + return value |
0 commit comments