Source code for toolscore.mcp.config

"""Parse Claude Desktop style MCP server configuration files.

The de-facto configuration format used by Claude Desktop and other MCP hosts
stores server definitions under an ``mcpServers`` mapping::

    {
        "mcpServers": {
            "filesystem": {
                "command": "npx",
                "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
                "env": {"DEBUG": "1"}
            }
        }
    }

This module turns such a file into a :class:`MCPServerSpec` ready to be passed
to :class:`toolscore.mcp.client.MCPStdioClient`.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path


[docs] @dataclass class MCPServerSpec: """A single MCP server entry resolved from a config file. Attributes: name: The key under ``mcpServers`` identifying this server. command: The full command vector, i.e. ``[command, *args]``. env: Environment variable overrides for the server process. """ name: str command: list[str] env: dict[str, str] = field(default_factory=dict)
[docs] def load_mcp_config(path: str | Path, server: str | None = None) -> MCPServerSpec: """Load an MCP server specification from a Claude Desktop style config file. Args: path: Path to the JSON configuration file. server: Name of the server to load. If ``None`` and exactly one server is defined, that server is used. If ``None`` and multiple servers exist, a :class:`ValueError` is raised listing the available names. Returns: The resolved :class:`MCPServerSpec`. Raises: FileNotFoundError: If ``path`` does not exist. ValueError: If the file is malformed, the ``mcpServers`` key is missing or empty, the requested ``server`` is not found, or ``server`` is ``None`` while multiple servers are defined. """ config_path = Path(path) if not config_path.is_file(): raise FileNotFoundError(f"MCP config file not found: {config_path}") try: raw = json.loads(config_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ValueError(f"MCP config file is not valid JSON ({config_path}): {exc}") from exc if not isinstance(raw, dict): raise ValueError(f"MCP config file must contain a JSON object: {config_path}") servers = raw.get("mcpServers") if not isinstance(servers, dict) or not servers: raise ValueError( f"MCP config file {config_path} is missing a non-empty 'mcpServers' object." ) available = sorted(servers) if server is None: if len(servers) > 1: raise ValueError( f"Multiple MCP servers defined; specify one of: {', '.join(available)}" ) server = next(iter(servers)) elif server not in servers: raise ValueError( f"MCP server {server!r} not found in {config_path}. " f"Available servers: {', '.join(available)}" ) entry = servers[server] if not isinstance(entry, dict): raise ValueError(f"MCP server {server!r} must be a JSON object in {config_path}.") command = entry.get("command") if not isinstance(command, str) or not command: raise ValueError(f"MCP server {server!r} must define a non-empty string 'command'.") raw_args = entry.get("args", []) if not isinstance(raw_args, list) or not all(isinstance(arg, str) for arg in raw_args): raise ValueError(f"MCP server {server!r} 'args' must be a list of strings.") raw_env = entry.get("env", {}) if not isinstance(raw_env, dict) or not all( isinstance(key, str) and isinstance(value, str) for key, value in raw_env.items() ): raise ValueError(f"MCP server {server!r} 'env' must be a mapping of string to string.") return MCPServerSpec( name=server, command=[command, *raw_args], env=dict(raw_env), )