From 7d20254b438424016411502942b2501d53aea539 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 07:59:47 -0700 Subject: [PATCH 01/42] First cut at config API --- src/mcp/client/config/mcp_servers_config.py | 62 +++++++++++++++++++ tests/client/config/__init__.py | 1 + .../client/config/test_mcp_servers_config.py | 62 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 src/mcp/client/config/mcp_servers_config.py create mode 100644 tests/client/config/__init__.py create mode 100644 tests/client/config/test_mcp_servers_config.py diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py new file mode 100644 index 0000000000..d2afcf3c3b --- /dev/null +++ b/src/mcp/client/config/mcp_servers_config.py @@ -0,0 +1,62 @@ +"""Configuration management for MCP servers.""" + +# stdlib imports +import json +from pathlib import Path +from typing import Annotated, Any, Literal + +# third party imports +from pydantic import BaseModel, Field, model_validator + + +class MCPServerConfig(BaseModel): + """Base class for MCP server configurations.""" + + pass + + +class StdioServerConfig(MCPServerConfig): + """Configuration for stdio-based MCP servers.""" + + type: Literal["stdio"] = "stdio" + command: str + args: list[str] | None = None + env: dict[str, str] | None = None + + +class StreamableHttpConfig(MCPServerConfig): + """Configuration for StreamableHTTP-based MCP servers.""" + + type: Literal["streamable_http"] = "streamable_http" + url: str + headers: dict[str, str] | None = None + + +# Discriminated union for different server config types +ServerConfigUnion = Annotated[StdioServerConfig | StreamableHttpConfig, Field(discriminator="type")] + + +class MCPServersConfig(BaseModel): + """Configuration for multiple MCP servers.""" + + servers: dict[str, ServerConfigUnion] = Field(alias="mcpServers") + + @model_validator(mode="before") + @classmethod + def infer_server_types(cls, data: Any) -> Any: + """Automatically infer server types when 'type' field is omitted.""" + if isinstance(data, dict) and "mcpServers" in data: + for _server_name, server_config in data["mcpServers"].items(): # type: ignore + if isinstance(server_config, dict) and "type" not in server_config: + # Infer type based on distinguishing fields + if "command" in server_config: + server_config["type"] = "stdio" + elif "url" in server_config: + server_config["type"] = "streamable_http" + return data + + @classmethod + def from_file(cls, config_path: Path) -> "MCPServersConfig": + """Load configuration from a JSON file.""" + with open(config_path) as config_file: + return cls.model_validate(json.load(config_file)) diff --git a/tests/client/config/__init__.py b/tests/client/config/__init__.py new file mode 100644 index 0000000000..0519ecba6e --- /dev/null +++ b/tests/client/config/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py new file mode 100644 index 0000000000..8f7480df78 --- /dev/null +++ b/tests/client/config/test_mcp_servers_config.py @@ -0,0 +1,62 @@ +# stdlib imports +import json +from pathlib import Path + +# third party imports +import pytest + +# local imports +from mcp.client.config.mcp_servers_config import ( + MCPServersConfig, + StdioServerConfig, + StreamableHttpConfig, +) + + +@pytest.fixture +def mcp_config_file(tmp_path: Path) -> Path: + """Create temporary JSON config file with mixed server types""" + + config_data = { + "mcpServers": { + "stdio_server": { + "command": "python", + "args": ["-m", "my_server"], + "env": {"DEBUG": "true"}, + }, + "http_streamable": { + "url": "https://api.example.com/mcp", + "headers": {"Authorization": "Bearer token123"}, + }, + } + } + + # Write to temporary file + config_file_path = tmp_path / "mcp.json" + with open(config_file_path, "w") as config_file: + json.dump(config_data, config_file) + + return config_file_path + + +def test_stdio_server(mcp_config_file: Path): + config = MCPServersConfig.from_file(mcp_config_file) + + stdio_server = config.servers["stdio_server"] + assert isinstance(stdio_server, StdioServerConfig) + + assert stdio_server.command == "python" + assert stdio_server.args == ["-m", "my_server"] + assert stdio_server.env == {"DEBUG": "true"} + assert stdio_server.type == "stdio" # Should be automatically inferred + + +def test_streamable_http_server(mcp_config_file: Path): + config = MCPServersConfig.from_file(mcp_config_file) + + http_server = config.servers["http_streamable"] + assert isinstance(http_server, StreamableHttpConfig) + + assert http_server.url == "https://api.example.com/mcp" + assert http_server.headers == {"Authorization": "Bearer token123"} + assert http_server.type == "streamable_http" # Should be automatically inferred From c92c12fcd7748e0883f3eadac4eec69ab18ba416 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 08:31:41 -0700 Subject: [PATCH 02/42] Add effective_command and effective_args Add the ability to parse a multi-word command and extract the command and args into `effective_command` and `effective_args` attributes. --- src/mcp/client/config/mcp_servers_config.py | 13 +++++ .../client/config/test_mcp_servers_config.py | 47 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index d2afcf3c3b..7cd7cade7f 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -23,6 +23,19 @@ class StdioServerConfig(MCPServerConfig): args: list[str] | None = None env: dict[str, str] | None = None + @property + def effective_command(self) -> str: + """Get the effective command (first part of the command string).""" + return self.command.split()[0] + + @property + def effective_args(self) -> list[str]: + """Get the effective arguments (parsed from command plus explicit args).""" + command_parts = self.command.split() + parsed_args = command_parts[1:] if len(command_parts) > 1 else [] + explicit_args = self.args or [] + return parsed_args + explicit_args + class StreamableHttpConfig(MCPServerConfig): """Configuration for StreamableHTTP-based MCP servers.""" diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 8f7480df78..6135f7e06f 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -24,6 +24,13 @@ def mcp_config_file(tmp_path: Path) -> Path: "args": ["-m", "my_server"], "env": {"DEBUG": "true"}, }, + "stdio_server_with_full_command": { + "command": "python -m my_server", + }, + "stdio_server_with_full_command_and_explicit_args": { + "command": "python -m my_server", # Two args here: -m and my_server + "args": ["--debug"], # One explicit arg here: --debug + }, "http_streamable": { "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer token123"}, @@ -49,7 +56,45 @@ def test_stdio_server(mcp_config_file: Path): assert stdio_server.args == ["-m", "my_server"] assert stdio_server.env == {"DEBUG": "true"} assert stdio_server.type == "stdio" # Should be automatically inferred - + + # In this case, effective_command and effective_args are the same as command + # and args. + # But later on, we will see a test where the command is specified as a + # single string, and we expect the command to be split into command and args + assert stdio_server.effective_command == "python" + assert stdio_server.effective_args == ["-m", "my_server"] + + +def test_stdio_server_with_full_command_should_be_split(mcp_config_file: Path): + """This test should fail - it expects the command to be split into command and args.""" + config = MCPServersConfig.from_file(mcp_config_file) + + stdio_server = config.servers["stdio_server_with_full_command"] + assert isinstance(stdio_server, StdioServerConfig) + + # This is how the command was specified + assert stdio_server.command == "python -m my_server" + + # This is how the command is split into command and args + assert stdio_server.effective_command == "python" + assert stdio_server.effective_args == ["-m", "my_server"] + + +def test_stdio_server_with_full_command_and_explicit_args(mcp_config_file: Path): + """Test that effective_args combines parsed command args with explicit args.""" + config = MCPServersConfig.from_file(mcp_config_file) + + stdio_server = config.servers["stdio_server_with_full_command_and_explicit_args"] + assert isinstance(stdio_server, StdioServerConfig) + + # Test original values + assert stdio_server.command == "python -m my_server" + assert stdio_server.args == ["--debug"] + + # Test effective values - should combine parsed command args with explicit args + assert stdio_server.effective_command == "python" + assert stdio_server.effective_args == ["-m", "my_server", "--debug"] + def test_streamable_http_server(mcp_config_file: Path): config = MCPServersConfig.from_file(mcp_config_file) From 444960cf53cd332d0b03814fe608c53a31bb5ea8 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 08:54:52 -0700 Subject: [PATCH 03/42] Make infer_server_types typed; fix warnings --- src/mcp/client/config/mcp_servers_config.py | 25 +++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 7cd7cade7f..42b71ee82b 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -6,7 +6,7 @@ from typing import Annotated, Any, Literal # third party imports -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator class MCPServerConfig(BaseModel): @@ -54,19 +54,20 @@ class MCPServersConfig(BaseModel): servers: dict[str, ServerConfigUnion] = Field(alias="mcpServers") - @model_validator(mode="before") + @field_validator("servers", mode="before") @classmethod - def infer_server_types(cls, data: Any) -> Any: + def infer_server_types(cls, servers_data: dict[str, MCPServerConfig]) -> dict[str, MCPServerConfig]: """Automatically infer server types when 'type' field is omitted.""" - if isinstance(data, dict) and "mcpServers" in data: - for _server_name, server_config in data["mcpServers"].items(): # type: ignore - if isinstance(server_config, dict) and "type" not in server_config: - # Infer type based on distinguishing fields - if "command" in server_config: - server_config["type"] = "stdio" - elif "url" in server_config: - server_config["type"] = "streamable_http" - return data + + for server_config in servers_data.values(): + if isinstance(server_config, dict) and "type" not in server_config: + # Infer type based on distinguishing fields + if "command" in server_config: + server_config["type"] = "stdio" + elif "url" in server_config: + server_config["type"] = "streamable_http" + + return servers_data @classmethod def from_file(cls, config_path: Path) -> "MCPServersConfig": From 2bf423db697be51787846df54626387aa7b4e03d Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 09:13:31 -0700 Subject: [PATCH 04/42] test_explicit_types_are_respected --- src/mcp/client/config/mcp_servers_config.py | 13 ++++- .../client/config/test_mcp_servers_config.py | 53 ++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 42b71ee82b..8e17a9d4b0 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -45,8 +45,16 @@ class StreamableHttpConfig(MCPServerConfig): headers: dict[str, str] | None = None +class SSEServerConfig(MCPServerConfig): + """Configuration for SSE-based MCP servers.""" + + type: Literal["sse"] = "sse" + url: str + headers: dict[str, str] | None = None + + # Discriminated union for different server config types -ServerConfigUnion = Annotated[StdioServerConfig | StreamableHttpConfig, Field(discriminator="type")] +ServerConfigUnion = Annotated[StdioServerConfig | StreamableHttpConfig | SSEServerConfig, Field(discriminator="type")] class MCPServersConfig(BaseModel): @@ -56,7 +64,7 @@ class MCPServersConfig(BaseModel): @field_validator("servers", mode="before") @classmethod - def infer_server_types(cls, servers_data: dict[str, MCPServerConfig]) -> dict[str, MCPServerConfig]: + def infer_server_types(cls, servers_data: dict[str, Any]) -> dict[str, Any]: """Automatically infer server types when 'type' field is omitted.""" for server_config in servers_data.values(): @@ -65,6 +73,7 @@ def infer_server_types(cls, servers_data: dict[str, MCPServerConfig]) -> dict[st if "command" in server_config: server_config["type"] = "stdio" elif "url" in server_config: + # Could infer SSE vs streamable_http based on URL patterns in the future server_config["type"] = "streamable_http" return servers_data diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 6135f7e06f..95501a3864 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -8,6 +8,7 @@ # local imports from mcp.client.config.mcp_servers_config import ( MCPServersConfig, + SSEServerConfig, StdioServerConfig, StreamableHttpConfig, ) @@ -19,6 +20,7 @@ def mcp_config_file(tmp_path: Path) -> Path: config_data = { "mcpServers": { + # Servers with inferred types "stdio_server": { "command": "python", "args": ["-m", "my_server"], @@ -29,12 +31,29 @@ def mcp_config_file(tmp_path: Path) -> Path: }, "stdio_server_with_full_command_and_explicit_args": { "command": "python -m my_server", # Two args here: -m and my_server - "args": ["--debug"], # One explicit arg here: --debug + "args": ["--debug"], # One explicit arg here: --debug }, "http_streamable": { "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer token123"}, }, + # Servers with explicit types + "stdio_server_explicit": { + "type": "stdio", # Explicitly specified + "command": "python", + "args": ["-m", "my_server"], + "env": {"DEBUG": "true"}, + }, + "http_server_explicit_streamable_http": { + "type": "streamable_http", # Explicitly specified + "url": "https://api.example.com/mcp", + "headers": {"Authorization": "Bearer token123"}, + }, + "http_server_explicit_sse": { + "type": "sse", # Explicitly specified + "url": "https://api.example.com/sse", + "headers": {"Authorization": "Bearer token456"}, + }, } } @@ -65,6 +84,38 @@ def test_stdio_server(mcp_config_file: Path): assert stdio_server.effective_args == ["-m", "my_server"] +def test_explicit_types_are_respected(mcp_config_file: Path): + """Test that explicit 'type' fields in JSON config are respected and work correctly.""" + config = MCPServersConfig.from_file(mcp_config_file) + + # Test that servers are parsed correctly with explicit types + assert "stdio_server_explicit" in config.servers + assert "http_server_explicit_streamable_http" in config.servers + assert "http_server_explicit_sse" in config.servers + + # Test stdio server with explicit type + stdio_server = config.servers["stdio_server_explicit"] + assert isinstance(stdio_server, StdioServerConfig) + assert stdio_server.type == "stdio" + assert stdio_server.command == "python" + assert stdio_server.args == ["-m", "my_server"] + assert stdio_server.env == {"DEBUG": "true"} + + # Test HTTP server with explicit type + http_server = config.servers["http_server_explicit_streamable_http"] + assert isinstance(http_server, StreamableHttpConfig) + assert http_server.type == "streamable_http" + assert http_server.url == "https://api.example.com/mcp" + assert http_server.headers == {"Authorization": "Bearer token123"} + + # Test SSE server with explicit type + sse_server = config.servers["http_server_explicit_sse"] + assert isinstance(sse_server, SSEServerConfig) + assert sse_server.type == "sse" + assert sse_server.url == "https://api.example.com/sse" + assert sse_server.headers == {"Authorization": "Bearer token456"} + + def test_stdio_server_with_full_command_should_be_split(mcp_config_file: Path): """This test should fail - it expects the command to be split into command and args.""" config = MCPServersConfig.from_file(mcp_config_file) From 8ce12215865b1a4b43d9c1d9887a7906a7356750 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 09:18:49 -0700 Subject: [PATCH 05/42] More descriptive server names in tests --- .../client/config/test_mcp_servers_config.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 95501a3864..8af00205b3 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -33,23 +33,23 @@ def mcp_config_file(tmp_path: Path) -> Path: "command": "python -m my_server", # Two args here: -m and my_server "args": ["--debug"], # One explicit arg here: --debug }, - "http_streamable": { + "streamable_http_server_with_headers": { "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer token123"}, }, # Servers with explicit types - "stdio_server_explicit": { + "stdio_server_with_explicit_type": { "type": "stdio", # Explicitly specified "command": "python", "args": ["-m", "my_server"], "env": {"DEBUG": "true"}, }, - "http_server_explicit_streamable_http": { + "streamable_http_server_with_explicit_type": { "type": "streamable_http", # Explicitly specified "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer token123"}, }, - "http_server_explicit_sse": { + "sse_server_with_explicit_type": { "type": "sse", # Explicitly specified "url": "https://api.example.com/sse", "headers": {"Authorization": "Bearer token456"}, @@ -89,27 +89,27 @@ def test_explicit_types_are_respected(mcp_config_file: Path): config = MCPServersConfig.from_file(mcp_config_file) # Test that servers are parsed correctly with explicit types - assert "stdio_server_explicit" in config.servers - assert "http_server_explicit_streamable_http" in config.servers - assert "http_server_explicit_sse" in config.servers - + assert "stdio_server_with_explicit_type" in config.servers + assert "streamable_http_server_with_explicit_type" in config.servers + assert "sse_server_with_explicit_type" in config.servers + # Test stdio server with explicit type - stdio_server = config.servers["stdio_server_explicit"] + stdio_server = config.servers["stdio_server_with_explicit_type"] assert isinstance(stdio_server, StdioServerConfig) assert stdio_server.type == "stdio" assert stdio_server.command == "python" assert stdio_server.args == ["-m", "my_server"] assert stdio_server.env == {"DEBUG": "true"} - + # Test HTTP server with explicit type - http_server = config.servers["http_server_explicit_streamable_http"] + http_server = config.servers["streamable_http_server_with_explicit_type"] assert isinstance(http_server, StreamableHttpConfig) assert http_server.type == "streamable_http" assert http_server.url == "https://api.example.com/mcp" assert http_server.headers == {"Authorization": "Bearer token123"} # Test SSE server with explicit type - sse_server = config.servers["http_server_explicit_sse"] + sse_server = config.servers["sse_server_with_explicit_type"] assert isinstance(sse_server, SSEServerConfig) assert sse_server.type == "sse" assert sse_server.url == "https://api.example.com/sse" @@ -150,7 +150,7 @@ def test_stdio_server_with_full_command_and_explicit_args(mcp_config_file: Path) def test_streamable_http_server(mcp_config_file: Path): config = MCPServersConfig.from_file(mcp_config_file) - http_server = config.servers["http_streamable"] + http_server = config.servers["streamable_http_server_with_headers"] assert isinstance(http_server, StreamableHttpConfig) assert http_server.url == "https://api.example.com/mcp" From 8dfa25bd135216e04dc5731beca6c07d55924ad9 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 09:19:28 -0700 Subject: [PATCH 06/42] test_streamable_http_server => test_streamable_http_server_with_headers --- tests/client/config/test_mcp_servers_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 8af00205b3..c5c4231256 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -147,7 +147,7 @@ def test_stdio_server_with_full_command_and_explicit_args(mcp_config_file: Path) assert stdio_server.effective_args == ["-m", "my_server", "--debug"] -def test_streamable_http_server(mcp_config_file: Path): +def test_streamable_http_server_with_headers(mcp_config_file: Path): config = MCPServersConfig.from_file(mcp_config_file) http_server = config.servers["streamable_http_server_with_headers"] From 25b3a7b13e6ee259fc30bc8f25c94f14f0efaa10 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 09:25:03 -0700 Subject: [PATCH 07/42] Test refactoring --- .../client/config/test_mcp_servers_config.py | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index c5c4231256..bd08115ae8 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -47,12 +47,10 @@ def mcp_config_file(tmp_path: Path) -> Path: "streamable_http_server_with_explicit_type": { "type": "streamable_http", # Explicitly specified "url": "https://api.example.com/mcp", - "headers": {"Authorization": "Bearer token123"}, }, "sse_server_with_explicit_type": { "type": "sse", # Explicitly specified "url": "https://api.example.com/sse", - "headers": {"Authorization": "Bearer token456"}, }, } } @@ -84,36 +82,31 @@ def test_stdio_server(mcp_config_file: Path): assert stdio_server.effective_args == ["-m", "my_server"] -def test_explicit_types_are_respected(mcp_config_file: Path): - """Test that explicit 'type' fields in JSON config are respected and work correctly.""" +def test_stdio_server_with_explicit_type(mcp_config_file: Path): + """Test that stdio server with explicit 'type' field is respected and works correctly.""" config = MCPServersConfig.from_file(mcp_config_file) - # Test that servers are parsed correctly with explicit types - assert "stdio_server_with_explicit_type" in config.servers - assert "streamable_http_server_with_explicit_type" in config.servers - assert "sse_server_with_explicit_type" in config.servers - - # Test stdio server with explicit type stdio_server = config.servers["stdio_server_with_explicit_type"] assert isinstance(stdio_server, StdioServerConfig) assert stdio_server.type == "stdio" - assert stdio_server.command == "python" - assert stdio_server.args == ["-m", "my_server"] - assert stdio_server.env == {"DEBUG": "true"} - - # Test HTTP server with explicit type + + +def test_streamable_http_server_with_explicit_type(mcp_config_file: Path): + """Test that streamable HTTP server with explicit 'type' field is respected and works correctly.""" + config = MCPServersConfig.from_file(mcp_config_file) + http_server = config.servers["streamable_http_server_with_explicit_type"] assert isinstance(http_server, StreamableHttpConfig) assert http_server.type == "streamable_http" - assert http_server.url == "https://api.example.com/mcp" - assert http_server.headers == {"Authorization": "Bearer token123"} - # Test SSE server with explicit type + +def test_sse_server_with_explicit_type(mcp_config_file: Path): + """Test that SSE server with explicit 'type' field is respected and works correctly.""" + config = MCPServersConfig.from_file(mcp_config_file) + sse_server = config.servers["sse_server_with_explicit_type"] assert isinstance(sse_server, SSEServerConfig) assert sse_server.type == "sse" - assert sse_server.url == "https://api.example.com/sse" - assert sse_server.headers == {"Authorization": "Bearer token456"} def test_stdio_server_with_full_command_should_be_split(mcp_config_file: Path): From 37a6bb07fbcb3fc4973b34b6bd0e8600c43575d9 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 09:50:04 -0700 Subject: [PATCH 08/42] Move test fixtures from Python to mcp.json More realistic --- src/mcp/client/config/mcp_servers_config.py | 4 +- tests/client/config/mcp.json | 34 ++++++++++++ .../client/config/test_mcp_servers_config.py | 55 ++----------------- 3 files changed, 42 insertions(+), 51 deletions(-) create mode 100644 tests/client/config/mcp.json diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 8e17a9d4b0..290bc224ed 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -37,7 +37,7 @@ def effective_args(self) -> list[str]: return parsed_args + explicit_args -class StreamableHttpConfig(MCPServerConfig): +class StreamableHTTPServerConfig(MCPServerConfig): """Configuration for StreamableHTTP-based MCP servers.""" type: Literal["streamable_http"] = "streamable_http" @@ -54,7 +54,7 @@ class SSEServerConfig(MCPServerConfig): # Discriminated union for different server config types -ServerConfigUnion = Annotated[StdioServerConfig | StreamableHttpConfig | SSEServerConfig, Field(discriminator="type")] +ServerConfigUnion = Annotated[StdioServerConfig | StreamableHTTPServerConfig | SSEServerConfig, Field(discriminator="type")] class MCPServersConfig(BaseModel): diff --git a/tests/client/config/mcp.json b/tests/client/config/mcp.json new file mode 100644 index 0000000000..edfa0d45bf --- /dev/null +++ b/tests/client/config/mcp.json @@ -0,0 +1,34 @@ +{ + "mcpServers": { + "stdio_server": { + "command": "python", + "args": ["-m", "my_server"], + "env": {"DEBUG": "true"} + }, + "stdio_server_with_full_command": { + "command": "python -m my_server" + }, + "stdio_server_with_full_command_and_explicit_args": { + "command": "python -m my_server", + "args": ["--debug"] + }, + "streamable_http_server_with_headers": { + "url": "https://api.example.com/mcp", + "headers": {"Authorization": "Bearer token123"} + }, + "stdio_server_with_explicit_type": { + "type": "stdio", + "command": "python", + "args": ["-m", "my_server"], + "env": {"DEBUG": "true"} + }, + "streamable_http_server_with_explicit_type": { + "type": "streamable_http", + "url": "https://api.example.com/mcp" + }, + "sse_server_with_explicit_type": { + "type": "sse", + "url": "https://api.example.com/sse" + } + } +} \ No newline at end of file diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index bd08115ae8..f3c25818b1 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -10,57 +10,14 @@ MCPServersConfig, SSEServerConfig, StdioServerConfig, - StreamableHttpConfig, + StreamableHTTPServerConfig, ) @pytest.fixture -def mcp_config_file(tmp_path: Path) -> Path: - """Create temporary JSON config file with mixed server types""" - - config_data = { - "mcpServers": { - # Servers with inferred types - "stdio_server": { - "command": "python", - "args": ["-m", "my_server"], - "env": {"DEBUG": "true"}, - }, - "stdio_server_with_full_command": { - "command": "python -m my_server", - }, - "stdio_server_with_full_command_and_explicit_args": { - "command": "python -m my_server", # Two args here: -m and my_server - "args": ["--debug"], # One explicit arg here: --debug - }, - "streamable_http_server_with_headers": { - "url": "https://api.example.com/mcp", - "headers": {"Authorization": "Bearer token123"}, - }, - # Servers with explicit types - "stdio_server_with_explicit_type": { - "type": "stdio", # Explicitly specified - "command": "python", - "args": ["-m", "my_server"], - "env": {"DEBUG": "true"}, - }, - "streamable_http_server_with_explicit_type": { - "type": "streamable_http", # Explicitly specified - "url": "https://api.example.com/mcp", - }, - "sse_server_with_explicit_type": { - "type": "sse", # Explicitly specified - "url": "https://api.example.com/sse", - }, - } - } - - # Write to temporary file - config_file_path = tmp_path / "mcp.json" - with open(config_file_path, "w") as config_file: - json.dump(config_data, config_file) - - return config_file_path +def mcp_config_file() -> Path: + """Return path to the mcp.json config file with mixed server types""" + return Path(__file__).parent / "mcp.json" def test_stdio_server(mcp_config_file: Path): @@ -96,7 +53,7 @@ def test_streamable_http_server_with_explicit_type(mcp_config_file: Path): config = MCPServersConfig.from_file(mcp_config_file) http_server = config.servers["streamable_http_server_with_explicit_type"] - assert isinstance(http_server, StreamableHttpConfig) + assert isinstance(http_server, StreamableHTTPServerConfig) assert http_server.type == "streamable_http" @@ -144,7 +101,7 @@ def test_streamable_http_server_with_headers(mcp_config_file: Path): config = MCPServersConfig.from_file(mcp_config_file) http_server = config.servers["streamable_http_server_with_headers"] - assert isinstance(http_server, StreamableHttpConfig) + assert isinstance(http_server, StreamableHTTPServerConfig) assert http_server.url == "https://api.example.com/mcp" assert http_server.headers == {"Authorization": "Bearer token123"} From 7d63c269d9092f56ab5de34a9da50807b6ba4fc0 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 09:50:40 -0700 Subject: [PATCH 09/42] ruff format --- src/mcp/client/config/mcp_servers_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 290bc224ed..eec893cd40 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -54,7 +54,9 @@ class SSEServerConfig(MCPServerConfig): # Discriminated union for different server config types -ServerConfigUnion = Annotated[StdioServerConfig | StreamableHTTPServerConfig | SSEServerConfig, Field(discriminator="type")] +ServerConfigUnion = Annotated[ + StdioServerConfig | StreamableHTTPServerConfig | SSEServerConfig, Field(discriminator="type") +] class MCPServersConfig(BaseModel): From 38eff49a4173b237cc72cdd94ee7e39c27246df0 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 09:56:29 -0700 Subject: [PATCH 10/42] Add JSONC support --- tests/client/config/mcp.jsonc | 57 +++++++ tests/client/config/test_mcp_jsonc_config.py | 167 +++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 tests/client/config/mcp.jsonc create mode 100644 tests/client/config/test_mcp_jsonc_config.py diff --git a/tests/client/config/mcp.jsonc b/tests/client/config/mcp.jsonc new file mode 100644 index 0000000000..92016aa449 --- /dev/null +++ b/tests/client/config/mcp.jsonc @@ -0,0 +1,57 @@ +{ + // MCP Servers Configuration + // This file demonstrates various server configurations for testing + "mcpServers": { + + // Basic stdio server with explicit command and args + // Type will be automatically inferred from the "command" field + "stdio_server": { + "command": "python", + "args": ["-m", "my_server"], + "env": {"DEBUG": "true"} + }, + + // Stdio server with full command string that needs to be parsed + // The effective_command will be "python" and effective_args will be ["-m", "my_server"] + "stdio_server_with_full_command": { + "command": "python -m my_server" + }, + + // Stdio server with full command string AND explicit args + // The effective_args will combine parsed command args with explicit args + "stdio_server_with_full_command_and_explicit_args": { + "command": "python -m my_server", // Will be parsed to: command="python", args=["-m", "my_server"] + "args": ["--debug"] // Will be appended to parsed args: ["-m", "my_server", "--debug"] + }, + + // Streamable HTTP server with headers + // Type will be automatically inferred from the "url" field + "streamable_http_server_with_headers": { + "url": "https://api.example.com/mcp", + "headers": {"Authorization": "Bearer token123"} + }, + + // Servers with explicit types - these demonstrate that type inference + // can be overridden by explicitly specifying the "type" field + + // Stdio server with explicit type specification + "stdio_server_with_explicit_type": { + "type": "stdio", // Explicitly specified type + "command": "python", + "args": ["-m", "my_server"], + "env": {"DEBUG": "true"} + }, + + // Streamable HTTP server with explicit type specification + "streamable_http_server_with_explicit_type": { + "type": "streamable_http", // Explicitly specified type + "url": "https://api.example.com/mcp" + }, + + // SSE (Server-Sent Events) server with explicit type specification + "sse_server_with_explicit_type": { + "type": "sse", // Explicitly specified type + "url": "https://api.example.com/sse" + } + } +} diff --git a/tests/client/config/test_mcp_jsonc_config.py b/tests/client/config/test_mcp_jsonc_config.py new file mode 100644 index 0000000000..2ca31811b7 --- /dev/null +++ b/tests/client/config/test_mcp_jsonc_config.py @@ -0,0 +1,167 @@ +# stdlib imports +import json +import re +from pathlib import Path + +# third party imports +import pytest + +# local imports +from mcp.client.config.mcp_servers_config import ( + MCPServersConfig, + SSEServerConfig, + StdioServerConfig, + StreamableHTTPServerConfig, +) + + +def strip_jsonc_comments(jsonc_content: str) -> str: + """ + Simple function to strip comments from JSONC content. + This handles basic line comments (//) and block comments (/* */). + """ + # Remove single-line comments (// comment) + lines = jsonc_content.split("\n") + processed_lines = [] + + for line in lines: + # Find the position of // that's not inside a string + in_string = False + escaped = False + comment_pos = -1 + + for i, char in enumerate(line): + if escaped: + escaped = False + continue + + if char == "\\": + escaped = True + continue + + if char == '"' and not escaped: + in_string = not in_string + continue + + if not in_string and char == "/" and i + 1 < len(line) and line[i + 1] == "/": + comment_pos = i + break + + if comment_pos >= 0: + line = line[:comment_pos].rstrip() + + processed_lines.append(line) + + content = "\n".join(processed_lines) + + # Remove block comments (/* comment */) + # This is a simplified approach - not perfect for all edge cases + content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) + + return content + + +@pytest.fixture +def mcp_jsonc_config_file() -> Path: + """Return path to the mcp.jsonc config file with comments""" + return Path(__file__).parent / "mcp.jsonc" + + +def test_jsonc_file_exists(mcp_jsonc_config_file: Path): + """Test that the JSONC configuration file exists.""" + assert mcp_jsonc_config_file.exists(), f"JSONC config file not found: {mcp_jsonc_config_file}" + + +def test_jsonc_content_can_be_parsed(): + """Test that JSONC content can be parsed after stripping comments.""" + jsonc_file = Path(__file__).parent / "mcp.jsonc" + + with open(jsonc_file) as f: + jsonc_content = f.read() + + # Strip comments and parse as JSON + json_content = strip_jsonc_comments(jsonc_content) + parsed_data = json.loads(json_content) + + # Validate the structure + assert "mcpServers" in parsed_data + assert isinstance(parsed_data["mcpServers"], dict) + + # Check that some expected servers are present + servers = parsed_data["mcpServers"] + assert "stdio_server" in servers + assert "streamable_http_server_with_headers" in servers + assert "sse_server_with_explicit_type" in servers + + +def test_jsonc_config_can_be_loaded_as_mcp_config(): + """Test that JSONC content can be loaded into MCPServersConfig after processing.""" + jsonc_file = Path(__file__).parent / "mcp.jsonc" + + with open(jsonc_file) as f: + jsonc_content = f.read() + + # Strip comments and create config + json_content = strip_jsonc_comments(jsonc_content) + parsed_data = json.loads(json_content) + config = MCPServersConfig.model_validate(parsed_data) + + # Test that all expected servers are loaded correctly + assert len(config.servers) == 7 # Should have 7 servers total + + # Test stdio server + stdio_server = config.servers["stdio_server"] + assert isinstance(stdio_server, StdioServerConfig) + assert stdio_server.command == "python" + assert stdio_server.type == "stdio" + + # Test streamable HTTP server + http_server = config.servers["streamable_http_server_with_headers"] + assert isinstance(http_server, StreamableHTTPServerConfig) + assert http_server.url == "https://api.example.com/mcp" + assert http_server.type == "streamable_http" + + # Test SSE server + sse_server = config.servers["sse_server_with_explicit_type"] + assert isinstance(sse_server, SSEServerConfig) + assert sse_server.url == "https://api.example.com/sse" + assert sse_server.type == "sse" + + +def test_jsonc_comments_are_properly_stripped(): + """Test that various comment types are properly stripped from JSONC.""" + test_jsonc = """ + { + // This is a line comment + "key1": "value1", + "key2": "value with // not a comment inside string", + /* This is a + block comment */ + "key3": "value3" // Another line comment + } + """ + + result = strip_jsonc_comments(test_jsonc) + parsed = json.loads(result) + + assert parsed["key1"] == "value1" + assert parsed["key2"] == "value with // not a comment inside string" + assert parsed["key3"] == "value3" + + +def test_jsonc_and_json_configs_are_equivalent(): + """Test that the JSONC and JSON configs contain the same data after comment removal.""" + json_file = Path(__file__).parent / "mcp.json" + jsonc_file = Path(__file__).parent / "mcp.jsonc" + + # Load JSON config + with open(json_file) as f: + json_data = json.load(f) + + # Load JSONC config and strip comments + with open(jsonc_file) as f: + jsonc_content = f.read() + jsonc_data = json.loads(strip_jsonc_comments(jsonc_content)) + + # They should be equivalent + assert json_data == jsonc_data From 71adf901aa9f9de1fd14836d7820cf21356afaf2 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 10:07:01 -0700 Subject: [PATCH 11/42] Revert "Add JSONC support" This reverts commit 38eff49a4173b237cc72cdd94ee7e39c27246df0. --- tests/client/config/mcp.jsonc | 57 ------- tests/client/config/test_mcp_jsonc_config.py | 167 ------------------- 2 files changed, 224 deletions(-) delete mode 100644 tests/client/config/mcp.jsonc delete mode 100644 tests/client/config/test_mcp_jsonc_config.py diff --git a/tests/client/config/mcp.jsonc b/tests/client/config/mcp.jsonc deleted file mode 100644 index 92016aa449..0000000000 --- a/tests/client/config/mcp.jsonc +++ /dev/null @@ -1,57 +0,0 @@ -{ - // MCP Servers Configuration - // This file demonstrates various server configurations for testing - "mcpServers": { - - // Basic stdio server with explicit command and args - // Type will be automatically inferred from the "command" field - "stdio_server": { - "command": "python", - "args": ["-m", "my_server"], - "env": {"DEBUG": "true"} - }, - - // Stdio server with full command string that needs to be parsed - // The effective_command will be "python" and effective_args will be ["-m", "my_server"] - "stdio_server_with_full_command": { - "command": "python -m my_server" - }, - - // Stdio server with full command string AND explicit args - // The effective_args will combine parsed command args with explicit args - "stdio_server_with_full_command_and_explicit_args": { - "command": "python -m my_server", // Will be parsed to: command="python", args=["-m", "my_server"] - "args": ["--debug"] // Will be appended to parsed args: ["-m", "my_server", "--debug"] - }, - - // Streamable HTTP server with headers - // Type will be automatically inferred from the "url" field - "streamable_http_server_with_headers": { - "url": "https://api.example.com/mcp", - "headers": {"Authorization": "Bearer token123"} - }, - - // Servers with explicit types - these demonstrate that type inference - // can be overridden by explicitly specifying the "type" field - - // Stdio server with explicit type specification - "stdio_server_with_explicit_type": { - "type": "stdio", // Explicitly specified type - "command": "python", - "args": ["-m", "my_server"], - "env": {"DEBUG": "true"} - }, - - // Streamable HTTP server with explicit type specification - "streamable_http_server_with_explicit_type": { - "type": "streamable_http", // Explicitly specified type - "url": "https://api.example.com/mcp" - }, - - // SSE (Server-Sent Events) server with explicit type specification - "sse_server_with_explicit_type": { - "type": "sse", // Explicitly specified type - "url": "https://api.example.com/sse" - } - } -} diff --git a/tests/client/config/test_mcp_jsonc_config.py b/tests/client/config/test_mcp_jsonc_config.py deleted file mode 100644 index 2ca31811b7..0000000000 --- a/tests/client/config/test_mcp_jsonc_config.py +++ /dev/null @@ -1,167 +0,0 @@ -# stdlib imports -import json -import re -from pathlib import Path - -# third party imports -import pytest - -# local imports -from mcp.client.config.mcp_servers_config import ( - MCPServersConfig, - SSEServerConfig, - StdioServerConfig, - StreamableHTTPServerConfig, -) - - -def strip_jsonc_comments(jsonc_content: str) -> str: - """ - Simple function to strip comments from JSONC content. - This handles basic line comments (//) and block comments (/* */). - """ - # Remove single-line comments (// comment) - lines = jsonc_content.split("\n") - processed_lines = [] - - for line in lines: - # Find the position of // that's not inside a string - in_string = False - escaped = False - comment_pos = -1 - - for i, char in enumerate(line): - if escaped: - escaped = False - continue - - if char == "\\": - escaped = True - continue - - if char == '"' and not escaped: - in_string = not in_string - continue - - if not in_string and char == "/" and i + 1 < len(line) and line[i + 1] == "/": - comment_pos = i - break - - if comment_pos >= 0: - line = line[:comment_pos].rstrip() - - processed_lines.append(line) - - content = "\n".join(processed_lines) - - # Remove block comments (/* comment */) - # This is a simplified approach - not perfect for all edge cases - content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) - - return content - - -@pytest.fixture -def mcp_jsonc_config_file() -> Path: - """Return path to the mcp.jsonc config file with comments""" - return Path(__file__).parent / "mcp.jsonc" - - -def test_jsonc_file_exists(mcp_jsonc_config_file: Path): - """Test that the JSONC configuration file exists.""" - assert mcp_jsonc_config_file.exists(), f"JSONC config file not found: {mcp_jsonc_config_file}" - - -def test_jsonc_content_can_be_parsed(): - """Test that JSONC content can be parsed after stripping comments.""" - jsonc_file = Path(__file__).parent / "mcp.jsonc" - - with open(jsonc_file) as f: - jsonc_content = f.read() - - # Strip comments and parse as JSON - json_content = strip_jsonc_comments(jsonc_content) - parsed_data = json.loads(json_content) - - # Validate the structure - assert "mcpServers" in parsed_data - assert isinstance(parsed_data["mcpServers"], dict) - - # Check that some expected servers are present - servers = parsed_data["mcpServers"] - assert "stdio_server" in servers - assert "streamable_http_server_with_headers" in servers - assert "sse_server_with_explicit_type" in servers - - -def test_jsonc_config_can_be_loaded_as_mcp_config(): - """Test that JSONC content can be loaded into MCPServersConfig after processing.""" - jsonc_file = Path(__file__).parent / "mcp.jsonc" - - with open(jsonc_file) as f: - jsonc_content = f.read() - - # Strip comments and create config - json_content = strip_jsonc_comments(jsonc_content) - parsed_data = json.loads(json_content) - config = MCPServersConfig.model_validate(parsed_data) - - # Test that all expected servers are loaded correctly - assert len(config.servers) == 7 # Should have 7 servers total - - # Test stdio server - stdio_server = config.servers["stdio_server"] - assert isinstance(stdio_server, StdioServerConfig) - assert stdio_server.command == "python" - assert stdio_server.type == "stdio" - - # Test streamable HTTP server - http_server = config.servers["streamable_http_server_with_headers"] - assert isinstance(http_server, StreamableHTTPServerConfig) - assert http_server.url == "https://api.example.com/mcp" - assert http_server.type == "streamable_http" - - # Test SSE server - sse_server = config.servers["sse_server_with_explicit_type"] - assert isinstance(sse_server, SSEServerConfig) - assert sse_server.url == "https://api.example.com/sse" - assert sse_server.type == "sse" - - -def test_jsonc_comments_are_properly_stripped(): - """Test that various comment types are properly stripped from JSONC.""" - test_jsonc = """ - { - // This is a line comment - "key1": "value1", - "key2": "value with // not a comment inside string", - /* This is a - block comment */ - "key3": "value3" // Another line comment - } - """ - - result = strip_jsonc_comments(test_jsonc) - parsed = json.loads(result) - - assert parsed["key1"] == "value1" - assert parsed["key2"] == "value with // not a comment inside string" - assert parsed["key3"] == "value3" - - -def test_jsonc_and_json_configs_are_equivalent(): - """Test that the JSONC and JSON configs contain the same data after comment removal.""" - json_file = Path(__file__).parent / "mcp.json" - jsonc_file = Path(__file__).parent / "mcp.jsonc" - - # Load JSON config - with open(json_file) as f: - json_data = json.load(f) - - # Load JSONC config and strip comments - with open(jsonc_file) as f: - jsonc_content = f.read() - jsonc_data = json.loads(strip_jsonc_comments(jsonc_content)) - - # They should be equivalent - assert json_data == jsonc_data From 68b1a2e8dbe57eed587bb2ccb862299fe891fb67 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 10:37:39 -0700 Subject: [PATCH 12/42] Add YAML support --- pyproject.toml | 1 + src/mcp/client/config/mcp_servers_config.py | 24 +++++- tests/client/config/mcp.yaml | 70 ++++++++++++++++ .../client/config/test_yaml_functionality.py | 83 +++++++++++++++++++ uv.lock | 6 +- 5 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 tests/client/config/mcp.yaml create mode 100644 tests/client/config/test_yaml_functionality.py diff --git a/pyproject.toml b/pyproject.toml index 9ad50ab583..7225c597f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ rich = ["rich>=13.9.4"] cli = ["typer>=0.12.4", "python-dotenv>=1.0.0"] ws = ["websockets>=15.0.1"] +yaml = ["pyyaml>=6.0.2"] [project.scripts] mcp = "mcp.cli:app [cli]" diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index eec893cd40..7ff4b5c586 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -6,6 +6,10 @@ from typing import Annotated, Any, Literal # third party imports +try: + import yaml +except ImportError: + yaml = None # type: ignore from pydantic import BaseModel, Field, field_validator @@ -81,7 +85,21 @@ def infer_server_types(cls, servers_data: dict[str, Any]) -> dict[str, Any]: return servers_data @classmethod - def from_file(cls, config_path: Path) -> "MCPServersConfig": - """Load configuration from a JSON file.""" + def from_file(cls, config_path: Path, use_pyyaml: bool = False) -> "MCPServersConfig": + """Load configuration from a JSON or YAML file. + + Args: + config_path: Path to the configuration file + use_pyyaml: If True, force use of PyYAML parser. Defaults to False. + Also automatically used for .yaml/.yml files. + """ with open(config_path) as config_file: - return cls.model_validate(json.load(config_file)) + # Check if YAML parsing is requested + should_use_yaml = use_pyyaml or config_path.suffix.lower() in (".yaml", ".yml") + + if should_use_yaml: + if not yaml: + raise ImportError("PyYAML is required to parse YAML files. ") + return cls.model_validate(yaml.safe_load(config_file)) + else: + return cls.model_validate(json.load(config_file)) diff --git a/tests/client/config/mcp.yaml b/tests/client/config/mcp.yaml new file mode 100644 index 0000000000..e06be36f83 --- /dev/null +++ b/tests/client/config/mcp.yaml @@ -0,0 +1,70 @@ +# MCP Servers Configuration (YAML format) +# This file demonstrates the same server configurations as mcp.json but in YAML format + +mcpServers: + time: + command: uvx mcp-server-time + + # Stdio server with full command string + # The library will automatically parse this. + # The effective_command will be "python" and effective_args will be ["-m", "my_server"] + stdio_server_with_full_command: + command: python -m my_server + + # Streamable HTTP server with headers + streamable_http_server: + url: https://api.example.com/mcp + + # Streamable HTTP server with headers + streamable_http_server_with_headers: + url: https://api.example.com/mcp + headers: + Authorization: Bearer token123 + + # stdio server with explicit command and args like typically done in mcp.json + # files + # I would expect this to not be used much with YAML files, but it's here for + # completeness. + stdio_server: + command: python + args: + - -m + - my_server + env: + DEBUG: "true" + + # Stdio server with full command string AND explicit args + # The effective_args will combine parsed command args with explicit args + stdio_server_with_full_command_and_explicit_args: + command: python -m my_server # Will be parsed to: command="python", args=["-m", "my_server"] + args: + - --debug # Will be appended to parsed args: ["-m", "my_server", "--debug"] + + # Streamable HTTP server with headers + streamable_http_server_with_headers: + url: https://api.example.com/mcp + headers: + Authorization: Bearer token123 + + # Servers with explicit types - these demonstrate that type inference + # can be overridden by explicitly specifying the "type" field + + # Stdio server with explicit type specification + stdio_server_with_explicit_type: + type: stdio # Explicitly specified type + command: python + args: + - -m + - my_server + env: + DEBUG: "true" + + # Streamable HTTP server with explicit type specification + streamable_http_server_with_explicit_type: + type: streamable_http # Explicitly specified type + url: https://api.example.com/mcp + + # SSE (Server-Sent Events) server with explicit type specification + sse_server_with_explicit_type: + type: sse # Explicitly specified type + url: https://api.example.com/sse diff --git a/tests/client/config/test_yaml_functionality.py b/tests/client/config/test_yaml_functionality.py new file mode 100644 index 0000000000..383610cf54 --- /dev/null +++ b/tests/client/config/test_yaml_functionality.py @@ -0,0 +1,83 @@ +# stdlib imports +from pathlib import Path + +# third party imports +import pytest + +# local imports +from mcp.client.config.mcp_servers_config import MCPServersConfig, StdioServerConfig, StreamableHTTPServerConfig + + +@pytest.fixture +def mcp_yaml_config_file() -> Path: + """Return path to the mcp.yaml config file.""" + return Path(__file__).parent / "mcp.yaml" + + +def test_yaml_extension_auto_detection(mcp_yaml_config_file: Path): + """Test that .yaml files are automatically parsed with PyYAML.""" + config = MCPServersConfig.from_file(mcp_yaml_config_file) + + # Should successfully load the YAML file with all 9 servers + assert "stdio_server" in config.servers + assert "streamable_http_server_with_headers" in config.servers + assert "sse_server_with_explicit_type" in config.servers + + # Verify a specific server + stdio_server = config.servers["stdio_server"] + assert isinstance(stdio_server, StdioServerConfig) + assert stdio_server.command == "python" + assert stdio_server.args == ["-m", "my_server"] + assert stdio_server.env == {"DEBUG": "true"} + + +def test_use_pyyaml_parameter_with_json_file(): + """Test that use_pyyaml=True forces PyYAML parsing even for JSON files.""" + json_file = Path(__file__).parent / "mcp.json" + + # Load with PyYAML explicitly + config = MCPServersConfig.from_file(json_file, use_pyyaml=True) + + # Should work fine - PyYAML can parse JSON + assert len(config.servers) == 7 + assert "stdio_server" in config.servers + + # Verify it produces the same result as normal JSON parsing + config_json = MCPServersConfig.from_file(json_file, use_pyyaml=False) + assert len(config.servers) == len(config_json.servers) + assert list(config.servers.keys()) == list(config_json.servers.keys()) + + +def test_time_server(mcp_yaml_config_file: Path): + """Test the time server configuration with uvx command.""" + config = MCPServersConfig.from_file(mcp_yaml_config_file) + + # Should have the time server + assert "time" in config.servers + + # Verify the server configuration + time_server = config.servers["time"] + assert isinstance(time_server, StdioServerConfig) + assert time_server.type == "stdio" # Should be auto-inferred from command field + assert time_server.command == "uvx mcp-server-time" + assert time_server.args is None # No explicit args + assert time_server.env is None # No environment variables + + # Test the effective command parsing + assert time_server.effective_command == "uvx" + assert time_server.effective_args == ["mcp-server-time"] + + +def test_streamable_http_server(mcp_yaml_config_file: Path): + """Test the new streamable HTTP server configuration without headers.""" + config = MCPServersConfig.from_file(mcp_yaml_config_file) + + # Should have the new streamable_http_server + assert "streamable_http_server" in config.servers + + # Verify the server configuration + http_server = config.servers["streamable_http_server"] + assert isinstance(http_server, StreamableHTTPServerConfig) + assert http_server.type == "streamable_http" # Should be auto-inferred + assert http_server.url == "https://api.example.com/mcp" + assert http_server.headers is None # No headers specified diff --git a/uv.lock b/uv.lock index 180d5a9c1c..525866c441 100644 --- a/uv.lock +++ b/uv.lock @@ -551,6 +551,9 @@ rich = [ ws = [ { name = "websockets" }, ] +yaml = [ + { name = "pyyaml" }, +] [package.dev-dependencies] dev = [ @@ -580,6 +583,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0.2" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=1.6.1" }, { name = "starlette", specifier = ">=0.27" }, @@ -587,7 +591,7 @@ requires-dist = [ { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, ] -provides-extras = ["cli", "rich", "ws"] +provides-extras = ["cli", "rich", "ws", "yaml"] [package.metadata.requires-dev] dev = [ From a707aebc4dc8dff92f709cb822665fbf6973f587 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 12:17:43 -0700 Subject: [PATCH 13/42] Use shlex.split to parse command instead of str.split, which breaks when there are quoted arguments with spaces in them. --- src/mcp/client/config/mcp_servers_config.py | 9 ++++- .../client/config/test_mcp_servers_config.py | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 7ff4b5c586..04b8217051 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -2,6 +2,7 @@ # stdlib imports import json +import shlex from pathlib import Path from typing import Annotated, Any, Literal @@ -27,15 +28,19 @@ class StdioServerConfig(MCPServerConfig): args: list[str] | None = None env: dict[str, str] | None = None + def _parse_command(self) -> list[str]: + """Parse the command string into parts, handling quotes properly.""" + return shlex.split(self.command) + @property def effective_command(self) -> str: """Get the effective command (first part of the command string).""" - return self.command.split()[0] + return self._parse_command()[0] @property def effective_args(self) -> list[str]: """Get the effective arguments (parsed from command plus explicit args).""" - command_parts = self.command.split() + command_parts = self._parse_command() parsed_args = command_parts[1:] if len(command_parts) > 1 else [] explicit_args = self.args or [] return parsed_args + explicit_args diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index f3c25818b1..66230db808 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -106,3 +106,42 @@ def test_streamable_http_server_with_headers(mcp_config_file: Path): assert http_server.url == "https://api.example.com/mcp" assert http_server.headers == {"Authorization": "Bearer token123"} assert http_server.type == "streamable_http" # Should be automatically inferred + + +def test_stdio_server_with_quoted_arguments(): + """Test that stdio servers handle quoted arguments with spaces correctly.""" + # Test with double quotes + config_data = { + "mcpServers": { + "server_with_double_quotes": {"command": 'python -m my_server --config "path with spaces/config.json"'}, + "server_with_single_quotes": { + "command": "python -m my_server --config 'another path with spaces/config.json'" + }, + "server_with_mixed_quotes": { + "command": "python -m my_server --name \"My Server\" --path '/home/user/my path'" + }, + } + } + + config = MCPServersConfig.model_validate(config_data) + + # Test double quotes + double_quote_server = config.servers["server_with_double_quotes"] + assert isinstance(double_quote_server, StdioServerConfig) + assert double_quote_server.effective_command == "python" + expected_args_double = ["-m", "my_server", "--config", "path with spaces/config.json"] + assert double_quote_server.effective_args == expected_args_double + + # Test single quotes + single_quote_server = config.servers["server_with_single_quotes"] + assert isinstance(single_quote_server, StdioServerConfig) + assert single_quote_server.effective_command == "python" + expected_args_single = ["-m", "my_server", "--config", "another path with spaces/config.json"] + assert single_quote_server.effective_args == expected_args_single + + # Test mixed quotes + mixed_quote_server = config.servers["server_with_mixed_quotes"] + assert isinstance(mixed_quote_server, StdioServerConfig) + assert mixed_quote_server.effective_command == "python" + expected_args_mixed = ["-m", "my_server", "--name", "My Server", "--path", "/home/user/my path"] + assert mixed_quote_server.effective_args == expected_args_mixed From 729c0db694b095a75f2d0c58ae8052ab15c23499 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 12:23:48 -0700 Subject: [PATCH 14/42] More YAML tests --- tests/client/config/mcp.yaml | 9 ++---- .../client/config/test_yaml_functionality.py | 28 ++++++++++++++++++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/tests/client/config/mcp.yaml b/tests/client/config/mcp.yaml index e06be36f83..75454bd53f 100644 --- a/tests/client/config/mcp.yaml +++ b/tests/client/config/mcp.yaml @@ -5,6 +5,9 @@ mcpServers: time: command: uvx mcp-server-time + filesystem: + command: npx -y @modelcontextprotocol/server-filesystem /Users/username/Desktop /path/to/other/allowed/dir + # Stdio server with full command string # The library will automatically parse this. # The effective_command will be "python" and effective_args will be ["-m", "my_server"] @@ -40,12 +43,6 @@ mcpServers: args: - --debug # Will be appended to parsed args: ["-m", "my_server", "--debug"] - # Streamable HTTP server with headers - streamable_http_server_with_headers: - url: https://api.example.com/mcp - headers: - Authorization: Bearer token123 - # Servers with explicit types - these demonstrate that type inference # can be overridden by explicitly specifying the "type" field diff --git a/tests/client/config/test_yaml_functionality.py b/tests/client/config/test_yaml_functionality.py index 383610cf54..7e6c337e04 100644 --- a/tests/client/config/test_yaml_functionality.py +++ b/tests/client/config/test_yaml_functionality.py @@ -48,7 +48,7 @@ def test_use_pyyaml_parameter_with_json_file(): assert list(config.servers.keys()) == list(config_json.servers.keys()) -def test_time_server(mcp_yaml_config_file: Path): +def test_uvx_time_server(mcp_yaml_config_file: Path): """Test the time server configuration with uvx command.""" config = MCPServersConfig.from_file(mcp_yaml_config_file) @@ -81,3 +81,29 @@ def test_streamable_http_server(mcp_yaml_config_file: Path): assert http_server.type == "streamable_http" # Should be auto-inferred assert http_server.url == "https://api.example.com/mcp" assert http_server.headers is None # No headers specified + + +def test_npx_filesystem_server(mcp_yaml_config_file: Path): + """Test the filesystem server configuration with full command string and multiple arguments.""" + config = MCPServersConfig.from_file(mcp_yaml_config_file) + + # Should have the filesystem server + assert "filesystem" in config.servers + + # Verify the server configuration + filesystem_server = config.servers["filesystem"] + assert isinstance(filesystem_server, StdioServerConfig) + assert filesystem_server.type == "stdio" # Should be auto-inferred from command field + assert filesystem_server.command == "npx -y @modelcontextprotocol/server-filesystem /Users/username/Desktop /path/to/other/allowed/dir" + assert filesystem_server.args is None # No explicit args + assert filesystem_server.env is None # No environment variables + + # Test the effective command parsing + assert filesystem_server.effective_command == "npx" + expected_args = [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/Users/username/Desktop", + "/path/to/other/allowed/dir" + ] + assert filesystem_server.effective_args == expected_args From 651d504cc2af6e96e7fd0cbe713e64d3fa7e1fef Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 12:38:47 -0700 Subject: [PATCH 15/42] Handle multiline YAML commands with backslashes --- src/mcp/client/config/mcp_servers_config.py | 12 ++++++++++-- tests/client/config/mcp.yaml | 6 ++++++ tests/client/config/test_yaml_functionality.py | 5 ++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 04b8217051..7a1ad5f32a 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -29,8 +29,16 @@ class StdioServerConfig(MCPServerConfig): env: dict[str, str] | None = None def _parse_command(self) -> list[str]: - """Parse the command string into parts, handling quotes properly.""" - return shlex.split(self.command) + """Parse the command string into parts, handling quotes properly. + + Strips unnecessary whitespace and newlines to handle YAML multi-line strings. + Treats backslashes followed by newlines as line continuations. + """ + # Handle backslash line continuations by removing them and the following newline + cleaned_command = self.command.replace("\\\n", " ") + # Then normalize all whitespace (including remaining newlines) to single spaces + cleaned_command = " ".join(cleaned_command.split()) + return shlex.split(cleaned_command) @property def effective_command(self) -> str: diff --git a/tests/client/config/mcp.yaml b/tests/client/config/mcp.yaml index 75454bd53f..929caed728 100644 --- a/tests/client/config/mcp.yaml +++ b/tests/client/config/mcp.yaml @@ -8,6 +8,12 @@ mcpServers: filesystem: command: npx -y @modelcontextprotocol/server-filesystem /Users/username/Desktop /path/to/other/allowed/dir + filesystem_multi_line: + command: > + npx -y @modelcontextprotocol/server-filesystem \ + /Users/username/Desktop \ + /path/to/other/allowed/dir + # Stdio server with full command string # The library will automatically parse this. # The effective_command will be "python" and effective_args will be ["-m", "my_server"] diff --git a/tests/client/config/test_yaml_functionality.py b/tests/client/config/test_yaml_functionality.py index 7e6c337e04..e7ad9b9bc2 100644 --- a/tests/client/config/test_yaml_functionality.py +++ b/tests/client/config/test_yaml_functionality.py @@ -98,12 +98,11 @@ def test_npx_filesystem_server(mcp_yaml_config_file: Path): assert filesystem_server.args is None # No explicit args assert filesystem_server.env is None # No environment variables - # Test the effective command parsing + # Test the effective command and args parsing assert filesystem_server.effective_command == "npx" - expected_args = [ + assert filesystem_server.effective_args == [ "-y", "@modelcontextprotocol/server-filesystem", "/Users/username/Desktop", "/path/to/other/allowed/dir" ] - assert filesystem_server.effective_args == expected_args From 54d173ebf466288349b88ff01179b94c289027f0 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:00:21 -0700 Subject: [PATCH 16/42] Fix pre-commit failures --- tests/client/config/mcp.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/client/config/mcp.yaml b/tests/client/config/mcp.yaml index 929caed728..c0fda72b55 100644 --- a/tests/client/config/mcp.yaml +++ b/tests/client/config/mcp.yaml @@ -45,16 +45,16 @@ mcpServers: # Stdio server with full command string AND explicit args # The effective_args will combine parsed command args with explicit args stdio_server_with_full_command_and_explicit_args: - command: python -m my_server # Will be parsed to: command="python", args=["-m", "my_server"] + command: python -m my_server # Will be parsed to: command="python", args=["-m", "my_server"] args: - - --debug # Will be appended to parsed args: ["-m", "my_server", "--debug"] + - --debug # Will be appended to parsed args: ["-m", "my_server", "--debug"] # Servers with explicit types - these demonstrate that type inference # can be overridden by explicitly specifying the "type" field # Stdio server with explicit type specification stdio_server_with_explicit_type: - type: stdio # Explicitly specified type + type: stdio # Explicitly specified type command: python args: - -m @@ -64,10 +64,10 @@ mcpServers: # Streamable HTTP server with explicit type specification streamable_http_server_with_explicit_type: - type: streamable_http # Explicitly specified type + type: streamable_http # Explicitly specified type url: https://api.example.com/mcp # SSE (Server-Sent Events) server with explicit type specification sse_server_with_explicit_type: - type: sse # Explicitly specified type + type: sse # Explicitly specified type url: https://api.example.com/sse From 76ee90bf11ac1ad1ca6de51da1e41cdec84c675c Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:04:42 -0700 Subject: [PATCH 17/42] ruff format tests/client/config/test_yaml_functionality.py --- .../client/config/test_yaml_functionality.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/client/config/test_yaml_functionality.py b/tests/client/config/test_yaml_functionality.py index e7ad9b9bc2..f9e39a01ca 100644 --- a/tests/client/config/test_yaml_functionality.py +++ b/tests/client/config/test_yaml_functionality.py @@ -86,23 +86,26 @@ def test_streamable_http_server(mcp_yaml_config_file: Path): def test_npx_filesystem_server(mcp_yaml_config_file: Path): """Test the filesystem server configuration with full command string and multiple arguments.""" config = MCPServersConfig.from_file(mcp_yaml_config_file) - + # Should have the filesystem server assert "filesystem" in config.servers - + # Verify the server configuration filesystem_server = config.servers["filesystem"] assert isinstance(filesystem_server, StdioServerConfig) assert filesystem_server.type == "stdio" # Should be auto-inferred from command field - assert filesystem_server.command == "npx -y @modelcontextprotocol/server-filesystem /Users/username/Desktop /path/to/other/allowed/dir" + assert ( + filesystem_server.command + == "npx -y @modelcontextprotocol/server-filesystem /Users/username/Desktop /path/to/other/allowed/dir" + ) assert filesystem_server.args is None # No explicit args assert filesystem_server.env is None # No environment variables - + # Test the effective command and args parsing assert filesystem_server.effective_command == "npx" assert filesystem_server.effective_args == [ - "-y", - "@modelcontextprotocol/server-filesystem", - "/Users/username/Desktop", - "/path/to/other/allowed/dir" + "-y", + "@modelcontextprotocol/server-filesystem", + "/Users/username/Desktop", + "/path/to/other/allowed/dir", ] From e2e16f45b4c585f5a34467672fb38bdd47958c33 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:12:00 -0700 Subject: [PATCH 18/42] ruff format tests/client/config/__init__.py --- tests/client/config/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/client/config/__init__.py b/tests/client/config/__init__.py index 0519ecba6e..e69de29bb2 100644 --- a/tests/client/config/__init__.py +++ b/tests/client/config/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file From 8b3593c885cb1705928c57958991edc46053727e Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:14:45 -0700 Subject: [PATCH 19/42] Remove unnecessary `import json` --- tests/client/config/test_mcp_servers_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 66230db808..485697385d 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -1,5 +1,4 @@ # stdlib imports -import json from pathlib import Path # third party imports From f9d48d6293bb7e121c9cfb98d06dc7880ad93df8 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:15:15 -0700 Subject: [PATCH 20/42] Allow config_path: Path | str --- src/mcp/client/config/mcp_servers_config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 7a1ad5f32a..2963c5d43d 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -98,7 +98,7 @@ def infer_server_types(cls, servers_data: dict[str, Any]) -> dict[str, Any]: return servers_data @classmethod - def from_file(cls, config_path: Path, use_pyyaml: bool = False) -> "MCPServersConfig": + def from_file(cls, config_path: Path | str, use_pyyaml: bool = False) -> "MCPServersConfig": """Load configuration from a JSON or YAML file. Args: @@ -106,6 +106,10 @@ def from_file(cls, config_path: Path, use_pyyaml: bool = False) -> "MCPServersCo use_pyyaml: If True, force use of PyYAML parser. Defaults to False. Also automatically used for .yaml/.yml files. """ + + if isinstance(config_path, str): + config_path = Path(config_path) + with open(config_path) as config_file: # Check if YAML parsing is requested should_use_yaml = use_pyyaml or config_path.suffix.lower() in (".yaml", ".yml") From 4af7fe4165a7e3faaed0801791797292cce7e047 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:22:33 -0700 Subject: [PATCH 21/42] Allow config_path to use ~ for home dir --- src/mcp/client/config/mcp_servers_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 2963c5d43d..4a4501eb64 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -110,6 +110,8 @@ def from_file(cls, config_path: Path | str, use_pyyaml: bool = False) -> "MCPSer if isinstance(config_path, str): config_path = Path(config_path) + config_path = config_path.expanduser() + with open(config_path) as config_file: # Check if YAML parsing is requested should_use_yaml = use_pyyaml or config_path.suffix.lower() in (".yaml", ".yml") From 6574cfccab27ef4ae47a62340c640454736ddc69 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:34:03 -0700 Subject: [PATCH 22/42] Expand environment variables in config_path --- src/mcp/client/config/mcp_servers_config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 4a4501eb64..7111ff880e 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -2,6 +2,7 @@ # stdlib imports import json +import os import shlex from pathlib import Path from typing import Annotated, Any, Literal @@ -107,10 +108,9 @@ def from_file(cls, config_path: Path | str, use_pyyaml: bool = False) -> "MCPSer Also automatically used for .yaml/.yml files. """ - if isinstance(config_path, str): - config_path = Path(config_path) - - config_path = config_path.expanduser() + config_path = os.path.expandvars(config_path) # Expand environment variables like $HOME + config_path = Path(config_path) # Convert to Path object + config_path = config_path.expanduser() # Expand ~ to home directory with open(config_path) as config_file: # Check if YAML parsing is requested From dd7a77df6af35597faafc6808df66ba9f8991b1e Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:56:21 -0700 Subject: [PATCH 23/42] Add as_dict method --- src/mcp/client/config/mcp_servers_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 7111ff880e..d9758a9dee 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -18,7 +18,9 @@ class MCPServerConfig(BaseModel): """Base class for MCP server configurations.""" - pass + def as_dict(self) -> dict[str, Any]: + """Return the server configuration as a dictionary.""" + return self.model_dump(exclude_none=True) class StdioServerConfig(MCPServerConfig): From 66e0e0eb88a74b99e1c898a7ecad23712df6d383 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 14:59:23 -0700 Subject: [PATCH 24/42] Support both "servers" and "mcpServers" keys --- src/mcp/client/config/mcp_servers_config.py | 19 ++++++++- .../client/config/test_mcp_servers_config.py | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index d9758a9dee..1ff8f5ca24 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -12,7 +12,7 @@ import yaml except ImportError: yaml = None # type: ignore -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator class MCPServerConfig(BaseModel): @@ -82,7 +82,22 @@ class SSEServerConfig(MCPServerConfig): class MCPServersConfig(BaseModel): """Configuration for multiple MCP servers.""" - servers: dict[str, ServerConfigUnion] = Field(alias="mcpServers") + servers: dict[str, ServerConfigUnion] + + @model_validator(mode="before") + @classmethod + def handle_field_aliases(cls, data: dict[str, Any]) -> dict[str, Any]: + """Handle both 'servers' and 'mcpServers' field names.""" + + # If 'mcpServers' exists but 'servers' doesn't, use 'mcpServers' + if "mcpServers" in data and "servers" not in data: + data["servers"] = data["mcpServers"] + del data["mcpServers"] + # If both exist, prefer 'servers' and remove 'mcpServers' + elif "mcpServers" in data and "servers" in data: + del data["mcpServers"] + + return data @field_validator("servers", mode="before") @classmethod diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 485697385d..6cf11d04f6 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -144,3 +144,44 @@ def test_stdio_server_with_quoted_arguments(): assert mixed_quote_server.effective_command == "python" expected_args_mixed = ["-m", "my_server", "--name", "My Server", "--path", "/home/user/my path"] assert mixed_quote_server.effective_args == expected_args_mixed + + +def test_both_field_names_supported(): + """Test that both 'servers' and 'mcpServers' field names are supported.""" + # Test with 'mcpServers' field name (traditional format) + config_with_mcp_servers = MCPServersConfig.model_validate( + {"mcpServers": {"test_server": {"command": "python -m test_server", "type": "stdio"}}} + ) + + # Test with 'servers' field name (new format) + config_with_servers = MCPServersConfig.model_validate( + {"servers": {"test_server": {"command": "python -m test_server", "type": "stdio"}}} + ) + + # Both should produce identical results + assert config_with_mcp_servers.servers == config_with_servers.servers + assert "test_server" in config_with_mcp_servers.servers + assert "test_server" in config_with_servers.servers + + # Verify the server configurations are correct + server1 = config_with_mcp_servers.servers["test_server"] + server2 = config_with_servers.servers["test_server"] + + assert isinstance(server1, StdioServerConfig) + assert isinstance(server2, StdioServerConfig) + assert server1.command == server2.command == "python -m test_server" + + +def test_servers_field_takes_precedence(): + """Test that 'servers' field takes precedence when both are present.""" + config_data = { + "mcpServers": {"old_server": {"command": "python -m old_server", "type": "stdio"}}, + "servers": {"new_server": {"command": "python -m new_server", "type": "stdio"}}, + } + + config = MCPServersConfig.model_validate(config_data) + + # Should only have the 'servers' content, not 'mcpServers' + assert "new_server" in config.servers + assert "old_server" not in config.servers + assert len(config.servers) == 1 From 8c8e6575b0a1beed9e2889d0e0d3ac7720bd6414 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 15:21:58 -0700 Subject: [PATCH 25/42] Support VS Code "inputs" key --- src/mcp/client/config/mcp_servers_config.py | 103 ++++- .../client/config/test_mcp_servers_config.py | 402 ++++++++++++++++++ 2 files changed, 502 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 1ff8f5ca24..84e2000514 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -3,6 +3,7 @@ # stdlib imports import json import os +import re import shlex from pathlib import Path from typing import Annotated, Any, Literal @@ -15,6 +16,15 @@ from pydantic import BaseModel, Field, field_validator, model_validator +class InputDefinition(BaseModel): + """Definition of an input parameter.""" + + type: Literal["promptString"] = "promptString" + id: str + description: str | None = None + password: bool = False + + class MCPServerConfig(BaseModel): """Base class for MCP server configurations.""" @@ -83,6 +93,7 @@ class MCPServersConfig(BaseModel): """Configuration for multiple MCP servers.""" servers: dict[str, ServerConfigUnion] + inputs: list[InputDefinition] | None = None @model_validator(mode="before") @classmethod @@ -115,14 +126,80 @@ def infer_server_types(cls, servers_data: dict[str, Any]) -> dict[str, Any]: return servers_data + def get_required_inputs(self) -> list[str]: + """Get list of input IDs that are defined in the inputs section.""" + if not self.inputs: + return [] + return [input_def.id for input_def in self.inputs] + + def validate_inputs(self, provided_inputs: dict[str, str]) -> list[str]: + """Validate provided inputs against input definitions. + + Returns list of missing required input IDs. + """ + if not self.inputs: + return [] + + required_input_ids = self.get_required_inputs() + missing_inputs = [] + + for input_id in required_input_ids: + if input_id not in provided_inputs: + missing_inputs.append(input_id) + + return missing_inputs + + def get_input_description(self, input_id: str) -> str | None: + """Get the description for a specific input ID.""" + if not self.inputs: + return None + + for input_def in self.inputs: + if input_def.id == input_id: + return input_def.description + + return None + + @classmethod + def _substitute_inputs(cls, data: Any, inputs: dict[str, str]) -> Any: + """Recursively substitute ${input:key} placeholders with values from inputs dict.""" + if isinstance(data, str): + # Replace ${input:key} patterns with values from inputs + def replace_input(match: re.Match[str]) -> str: + key = match.group(1) + if key in inputs: + return inputs[key] + else: + raise ValueError(f"Missing input value for key: '{key}'") + + return re.sub(r"\$\{input:([^}]+)\}", replace_input, data) + + elif isinstance(data, dict): + result = {} # type: ignore + for k, v in data.items(): # type: ignore + result[k] = cls._substitute_inputs(v, inputs) # type: ignore + return result + + elif isinstance(data, list): + result = [] # type: ignore + for item in data: # type: ignore + result.append(cls._substitute_inputs(item, inputs)) # type: ignore + return result + + else: + return data + @classmethod - def from_file(cls, config_path: Path | str, use_pyyaml: bool = False) -> "MCPServersConfig": + def from_file( + cls, config_path: Path | str, use_pyyaml: bool = False, inputs: dict[str, str] | None = None + ) -> "MCPServersConfig": """Load configuration from a JSON or YAML file. Args: config_path: Path to the configuration file use_pyyaml: If True, force use of PyYAML parser. Defaults to False. Also automatically used for .yaml/.yml files. + inputs: Dictionary of input values to substitute for ${input:key} placeholders """ config_path = os.path.expandvars(config_path) # Expand environment variables like $HOME @@ -136,6 +213,26 @@ def from_file(cls, config_path: Path | str, use_pyyaml: bool = False) -> "MCPSer if should_use_yaml: if not yaml: raise ImportError("PyYAML is required to parse YAML files. ") - return cls.model_validate(yaml.safe_load(config_file)) + data = yaml.safe_load(config_file) else: - return cls.model_validate(json.load(config_file)) + data = json.load(config_file) + + # Create a preliminary config to validate inputs if they're defined + preliminary_config = cls.model_validate(data) + + # Validate inputs if provided and input definitions exist + if inputs is not None and preliminary_config.inputs: + missing_inputs = preliminary_config.validate_inputs(inputs) + if missing_inputs: + descriptions = [] + for input_id in missing_inputs: + desc = preliminary_config.get_input_description(input_id) + descriptions.append(f" - {input_id}: {desc or 'No description'}") + + raise ValueError(f"Missing required input values:\n" + "\n".join(descriptions)) + + # Substitute input placeholders if inputs provided + if inputs: + data = cls._substitute_inputs(data, inputs) + + return cls.model_validate(data) diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 6cf11d04f6..75e22f64dc 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -1,5 +1,6 @@ # stdlib imports from pathlib import Path +import json # third party imports import pytest @@ -185,3 +186,404 @@ def test_servers_field_takes_precedence(): assert "new_server" in config.servers assert "old_server" not in config.servers assert len(config.servers) == 1 + + +def test_input_substitution(): + """Test that ${input:key} placeholders are substituted correctly.""" + config_data = { + "servers": { + "azure_server": { + "type": "sse", + "url": "https://${input:app-name}.azurewebsites.net/mcp/sse", + "headers": {"Authorization": "Bearer ${input:api-token}", "X-Custom-Header": "${input:custom-value}"}, + }, + "stdio_server": { + "type": "stdio", + "command": "python -m ${input:module-name}", + "args": ["--config", "${input:config-file}"], + "env": {"API_KEY": "${input:api-key}", "ENV": "${input:environment}"}, + }, + } + } + + inputs = { + "app-name": "my-function-app", + "api-token": "abc123token", + "custom-value": "custom-header-value", + "module-name": "my_server", + "config-file": "/path/to/config.json", + "api-key": "secret-api-key", + "environment": "production", + } + + # Test substitution + substituted = MCPServersConfig._substitute_inputs(config_data, inputs) + config = MCPServersConfig.model_validate(substituted) + + # Test SSE server substitution + sse_server = config.servers["azure_server"] + assert isinstance(sse_server, SSEServerConfig) + assert sse_server.url == "https://my-function-app.azurewebsites.net/mcp/sse" + assert sse_server.headers == {"Authorization": "Bearer abc123token", "X-Custom-Header": "custom-header-value"} + + # Test stdio server substitution + stdio_server = config.servers["stdio_server"] + assert isinstance(stdio_server, StdioServerConfig) + assert stdio_server.command == "python -m my_server" + assert stdio_server.args == ["--config", "/path/to/config.json"] + assert stdio_server.env == {"API_KEY": "secret-api-key", "ENV": "production"} + + +def test_input_substitution_missing_key(): + """Test that missing input keys raise appropriate errors.""" + config_data = {"servers": {"test_server": {"type": "sse", "url": "https://${input:missing-key}.example.com"}}} + + inputs = {"other-key": "value"} + + with pytest.raises(ValueError, match="Missing input value for key: 'missing-key'"): + MCPServersConfig._substitute_inputs(config_data, inputs) + + +def test_input_substitution_partial(): + """Test that only specified placeholders are substituted.""" + config_data = { + "servers": { + "test_server": { + "type": "sse", + "url": "https://${input:app-name}.example.com/api/${input:version}", + "headers": {"Static-Header": "static-value", "Dynamic-Header": "${input:token}"}, + } + } + } + + inputs = { + "app-name": "myapp", + "token": "secret123", + # Note: 'version' is intentionally missing + } + + with pytest.raises(ValueError, match="Missing input value for key: 'version'"): + MCPServersConfig._substitute_inputs(config_data, inputs) + + +def test_from_file_with_inputs(tmp_path: Path): + """Test loading config from file with input substitution.""" + # Create test config file + config_content = { + "servers": { + "dynamic_server": { + "type": "streamable_http", + "url": "https://${input:host}/mcp/api", + "headers": {"Authorization": "Bearer ${input:token}"}, + } + } + } + + config_file = tmp_path / "test_config.json" + with open(config_file, "w") as f: + json.dump(config_content, f) + + inputs = {"host": "api.example.com", "token": "test-token-123"} + + # Load with input substitution + config = MCPServersConfig.from_file(config_file, inputs=inputs) + + server = config.servers["dynamic_server"] + assert isinstance(server, StreamableHTTPServerConfig) + assert server.url == "https://api.example.com/mcp/api" + assert server.headers == {"Authorization": "Bearer test-token-123"} + + +def test_from_file_without_inputs(tmp_path: Path): + """Test loading config from file without input substitution.""" + # Create test config file with placeholders + config_content = { + "servers": { + "static_server": {"type": "sse", "url": "https://static.example.com/mcp/sse"}, + "placeholder_server": {"type": "sse", "url": "https://${input:host}/mcp/sse"}, + } + } + + config_file = tmp_path / "test_config.json" + with open(config_file, "w") as f: + json.dump(config_content, f) + + # Load without input substitution - placeholders should remain + config = MCPServersConfig.from_file(config_file) + + static_server = config.servers["static_server"] + assert isinstance(static_server, SSEServerConfig) + assert static_server.url == "https://static.example.com/mcp/sse" + + placeholder_server = config.servers["placeholder_server"] + assert isinstance(placeholder_server, SSEServerConfig) + assert placeholder_server.url == "https://${input:host}/mcp/sse" # Unchanged + + +def test_input_substitution_yaml_file(tmp_path: Path): + """Test input substitution with YAML files.""" + yaml_content = """ +servers: + yaml_server: + type: stdio + command: python -m ${input:module} + args: + - --port + - "${input:port}" + env: + DEBUG: "${input:debug}" +""" + + config_file = tmp_path / "test_config.yaml" + config_file.write_text(yaml_content) + + inputs = {"module": "test_server", "port": "8080", "debug": "true"} + + config = MCPServersConfig.from_file(config_file, inputs=inputs) + + server = config.servers["yaml_server"] + assert isinstance(server, StdioServerConfig) + assert server.command == "python -m test_server" + assert server.args == ["--port", "8080"] + assert server.env == {"DEBUG": "true"} + + +def test_input_definitions_parsing(): + """Test parsing of input definitions from config.""" + config_data = { + "inputs": [ + {"type": "promptString", "id": "functionapp-name", "description": "Azure Functions App Name"}, + { + "type": "promptString", + "id": "api-token", + "description": "API Token for authentication", + "password": True, + }, + ], + "servers": { + "azure_server": { + "type": "sse", + "url": "https://${input:functionapp-name}.azurewebsites.net/mcp/sse", + "headers": {"Authorization": "Bearer ${input:api-token}"}, + } + }, + } + + config = MCPServersConfig.model_validate(config_data) + + # Test input definitions are parsed correctly + assert config.inputs is not None + assert len(config.inputs) == 2 + + app_name_input = config.inputs[0] + assert app_name_input.id == "functionapp-name" + assert app_name_input.description == "Azure Functions App Name" + assert app_name_input.password is False + assert app_name_input.type == "promptString" + + api_token_input = config.inputs[1] + assert api_token_input.id == "api-token" + assert api_token_input.description == "API Token for authentication" + assert api_token_input.password is True + assert api_token_input.type == "promptString" + + +def test_get_required_inputs(): + """Test getting list of required input IDs.""" + config_data = { + "inputs": [ + {"id": "input1", "description": "First input"}, + {"id": "input2", "description": "Second input"}, + {"id": "input3", "description": "Third input"}, + ], + "servers": {"test_server": {"type": "stdio", "command": "python test.py"}}, + } + + config = MCPServersConfig.model_validate(config_data) + required_inputs = config.get_required_inputs() + + assert required_inputs == ["input1", "input2", "input3"] + + +def test_get_required_inputs_no_inputs_defined(): + """Test getting required inputs when no inputs are defined.""" + config_data = {"servers": {"test_server": {"type": "stdio", "command": "python test.py"}}} + + config = MCPServersConfig.model_validate(config_data) + required_inputs = config.get_required_inputs() + + assert required_inputs == [] + + +def test_validate_inputs_all_provided(): + """Test input validation when all required inputs are provided.""" + config_data = { + "inputs": [ + {"id": "username", "description": "Username"}, + {"id": "password", "description": "Password", "password": True}, + ], + "servers": {"test_server": {"type": "stdio", "command": "python test.py"}}, + } + + config = MCPServersConfig.model_validate(config_data) + provided_inputs = {"username": "testuser", "password": "secret123"} + + missing_inputs = config.validate_inputs(provided_inputs) + assert missing_inputs == [] + + +def test_validate_inputs_some_missing(): + """Test input validation when some required inputs are missing.""" + config_data = { + "inputs": [ + {"id": "required1", "description": "First required input"}, + {"id": "required2", "description": "Second required input"}, + {"id": "required3", "description": "Third required input"}, + ], + "servers": {"test_server": {"type": "stdio", "command": "python test.py"}}, + } + + config = MCPServersConfig.model_validate(config_data) + provided_inputs = { + "required1": "value1", + # required2 and required3 are missing + } + + missing_inputs = config.validate_inputs(provided_inputs) + assert set(missing_inputs) == {"required2", "required3"} + + +def test_get_input_description(): + """Test getting input descriptions.""" + config_data = { + "inputs": [ + {"id": "api-key", "description": "API Key for authentication"}, + {"id": "host", "description": "Server hostname"}, + ], + "servers": {"test_server": {"type": "stdio", "command": "python test.py"}}, + } + + config = MCPServersConfig.model_validate(config_data) + + assert config.get_input_description("api-key") == "API Key for authentication" + assert config.get_input_description("host") == "Server hostname" + assert config.get_input_description("nonexistent") is None + + +def test_get_input_description_no_inputs(): + """Test getting input description when no inputs are defined.""" + config_data = {"servers": {"test_server": {"type": "stdio", "command": "python test.py"}}} + + config = MCPServersConfig.model_validate(config_data) + assert config.get_input_description("any-key") is None + + +def test_from_file_with_input_validation_success(tmp_path: Path): + """Test loading file with input definitions and successful validation.""" + config_content = { + "inputs": [ + {"id": "app-name", "description": "Application name"}, + {"id": "env", "description": "Environment (dev/prod)"}, + ], + "servers": { + "app_server": { + "type": "streamable_http", + "url": "https://${input:app-name}-${input:env}.example.com/mcp/api", + } + }, + } + + config_file = tmp_path / "test_config.json" + with open(config_file, "w") as f: + json.dump(config_content, f) + + inputs = {"app-name": "myapp", "env": "prod"} + + # Should load successfully with all required inputs provided + config = MCPServersConfig.from_file(config_file, inputs=inputs) + + server = config.servers["app_server"] + assert isinstance(server, StreamableHTTPServerConfig) + assert server.url == "https://myapp-prod.example.com/mcp/api" + + +def test_from_file_with_input_validation_failure(tmp_path: Path): + """Test loading file with input definitions and validation failure.""" + config_content = { + "inputs": [ + {"id": "required-key", "description": "A required API key"}, + {"id": "optional-host", "description": "Optional hostname"}, + ], + "servers": {"test_server": {"type": "sse", "url": "https://${input:optional-host}/api"}}, + } + + config_file = tmp_path / "test_config.json" + with open(config_file, "w") as f: + json.dump(config_content, f) + + inputs = { + # Missing 'required-key' and 'optional-host' + } + + # Should raise ValueError with helpful error message + with pytest.raises(ValueError, match="Missing required input values"): + MCPServersConfig.from_file(config_file, inputs=inputs) + + +def test_from_file_without_input_definitions_no_validation(tmp_path: Path): + """Test that configs without input definitions don't perform validation.""" + config_content = { + "servers": {"test_server": {"type": "stdio", "command": "python -m server --token ${input:token}"}} + } + + config_file = tmp_path / "test_config.json" + with open(config_file, "w") as f: + json.dump(config_content, f) + + # Even with empty inputs, should load fine since no input definitions exist + config = MCPServersConfig.from_file(config_file, inputs={}) + + server = config.servers["test_server"] + assert isinstance(server, StdioServerConfig) + # Placeholder should remain unchanged + assert server.command == "python -m server --token ${input:token}" + + +def test_input_definition_with_yaml_file(tmp_path: Path): + """Test input definitions work with YAML files.""" + yaml_content = """ +inputs: + - type: promptString + id: module-name + description: Python module to run + - type: promptString + id: config-path + description: Path to configuration file + +servers: + yaml_server: + type: stdio + command: python -m ${input:module-name} + args: + - --config + - ${input:config-path} +""" + + config_file = tmp_path / "test_config.yaml" + config_file.write_text(yaml_content) + + inputs = {"module-name": "test_module", "config-path": "/etc/config.json"} + + config = MCPServersConfig.from_file(config_file, inputs=inputs) + + # Verify input definitions were parsed + assert config.inputs is not None + assert len(config.inputs) == 2 + assert config.inputs[0].id == "module-name" + assert config.inputs[1].id == "config-path" + + # Verify substitution worked + server = config.servers["yaml_server"] + assert isinstance(server, StdioServerConfig) + assert server.command == "python -m test_module" + assert server.args == ["--config", "/etc/config.json"] From 85036e0e71afec7430ab56cf1fa13efa496e86a4 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 15:28:38 -0700 Subject: [PATCH 26/42] Strip out // comments (JSONC support) --- src/mcp/client/config/mcp_servers_config.py | 46 ++++- .../client/config/test_mcp_servers_config.py | 158 +++++++++++++++++- 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 84e2000514..98ebf0904f 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -189,6 +189,44 @@ def replace_input(match: re.Match[str]) -> str: else: return data + @classmethod + def _strip_json_comments(cls, content: str) -> str: + """Strip // comments from JSON content, being careful not to remove // inside strings.""" + result = [] + lines = content.split("\n") + + for line in lines: + # Track if we're inside a string + in_string = False + escaped = False + comment_start = -1 + + for i, char in enumerate(line): + if escaped: + escaped = False + continue + + if char == "\\" and in_string: + escaped = True + continue + + if char == '"': + in_string = not in_string + continue + + # Look for // comment start when not in string + if not in_string and char == "/" and i + 1 < len(line) and line[i + 1] == "/": + comment_start = i + break + + # If we found a comment, remove it + if comment_start != -1: + line = line[:comment_start].rstrip() + + result.append(line) + + return "\n".join(result) + @classmethod def from_file( cls, config_path: Path | str, use_pyyaml: bool = False, inputs: dict[str, str] | None = None @@ -207,15 +245,19 @@ def from_file( config_path = config_path.expanduser() # Expand ~ to home directory with open(config_path) as config_file: + content = config_file.read() + # Check if YAML parsing is requested should_use_yaml = use_pyyaml or config_path.suffix.lower() in (".yaml", ".yml") if should_use_yaml: if not yaml: raise ImportError("PyYAML is required to parse YAML files. ") - data = yaml.safe_load(config_file) + data = yaml.safe_load(content) else: - data = json.load(config_file) + # Strip comments from JSON content (JSONC support) + cleaned_content = cls._strip_json_comments(content) + data = json.loads(cleaned_content) # Create a preliminary config to validate inputs if they're defined preliminary_config = cls.model_validate(data) diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 75e22f64dc..4566a917fd 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -1,6 +1,6 @@ # stdlib imports -from pathlib import Path import json +from pathlib import Path # third party imports import pytest @@ -587,3 +587,159 @@ def test_input_definition_with_yaml_file(tmp_path: Path): assert isinstance(server, StdioServerConfig) assert server.command == "python -m test_module" assert server.args == ["--config", "/etc/config.json"] + + +def test_jsonc_comment_stripping(): + """Test stripping of // comments from JSONC content.""" + # Test basic comment stripping + content_with_comments = """ +{ + // This is a comment + "servers": { + "test_server": { + "type": "stdio", + "command": "python test.py" // End of line comment + } + }, + // Another comment + "inputs": [] // Final comment +} +""" + + stripped = MCPServersConfig._strip_json_comments(content_with_comments) + config = MCPServersConfig.model_validate(json.loads(stripped)) + + assert "test_server" in config.servers + server = config.servers["test_server"] + assert isinstance(server, StdioServerConfig) + assert server.command == "python test.py" + + +def test_jsonc_comments_inside_strings_preserved(): + """Test that // inside strings are not treated as comments.""" + content_with_urls = """ +{ + "servers": { + "web_server": { + "type": "sse", + "url": "https://example.com/api/endpoint" // This is a comment + }, + "protocol_server": { + "type": "stdio", + "command": "node server.js --url=http://localhost:3000" + } + } +} +""" + + stripped = MCPServersConfig._strip_json_comments(content_with_urls) + config = MCPServersConfig.model_validate(json.loads(stripped)) + + web_server = config.servers["web_server"] + assert isinstance(web_server, SSEServerConfig) + assert web_server.url == "https://example.com/api/endpoint" + + protocol_server = config.servers["protocol_server"] + assert isinstance(protocol_server, StdioServerConfig) + # The // in the URL should be preserved + assert "http://localhost:3000" in protocol_server.command + + +def test_jsonc_escaped_quotes_handling(): + """Test that escaped quotes in strings are handled correctly.""" + content_with_escaped = """ +{ + "servers": { + "test_server": { + "type": "stdio", + "command": "python -c \\"print('Hello // World')\\"", // Comment after escaped quotes + "description": "Server with \\"escaped quotes\\" and // in string" + } + } +} +""" + + stripped = MCPServersConfig._strip_json_comments(content_with_escaped) + config = MCPServersConfig.model_validate(json.loads(stripped)) + + server = config.servers["test_server"] + assert isinstance(server, StdioServerConfig) + # The command should preserve the escaped quotes and // inside the string + assert server.command == "python -c \"print('Hello // World')\"" + + +def test_from_file_with_jsonc_comments(tmp_path: Path): + """Test loading JSONC file with comments via from_file method.""" + jsonc_content = """ +{ + // Configuration for MCP servers + "inputs": [ + { + "type": "promptString", + "id": "api-key", // Secret API key + "description": "API Key for authentication" + } + ], + "servers": { + // Main server configuration + "main_server": { + "type": "sse", + "url": "https://api.example.com/mcp/sse", // Production URL + "headers": { + "Authorization": "Bearer ${input:api-key}" // Dynamic token + } + } + } + // End of configuration +} +""" + + config_file = tmp_path / "test_config.json" + config_file.write_text(jsonc_content) + + inputs = {"api-key": "secret123"} + + # Should load successfully despite comments + config = MCPServersConfig.from_file(config_file, inputs=inputs) + + # Verify input definitions were parsed + assert config.inputs is not None + assert len(config.inputs) == 1 + assert config.inputs[0].id == "api-key" + + # Verify server configuration and input substitution + server = config.servers["main_server"] + assert isinstance(server, SSEServerConfig) + assert server.url == "https://api.example.com/mcp/sse" + assert server.headers == {"Authorization": "Bearer secret123"} + + +def test_jsonc_multiline_strings_with_comments(): + """Test that comments in multiline scenarios are handled correctly.""" + content = """ +{ + "servers": { + "test1": { + // Comment before + "type": "stdio", // Comment after + "command": "python server.py" + }, // Comment after object + "test2": { "type": "sse", "url": "https://example.com" } // Inline comment + } +} +""" + + stripped = MCPServersConfig._strip_json_comments(content) + config = MCPServersConfig.model_validate(json.loads(stripped)) + + assert len(config.servers) == 2 + assert "test1" in config.servers + assert "test2" in config.servers + + test1 = config.servers["test1"] + assert isinstance(test1, StdioServerConfig) + assert test1.command == "python server.py" + + test2 = config.servers["test2"] + assert isinstance(test2, SSEServerConfig) + assert test2.url == "https://example.com" From 27e5b1287c5e1d7a888c3210671d092b7b433ade Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 15:31:43 -0700 Subject: [PATCH 27/42] Fix lint issues --- src/mcp/client/config/mcp_servers_config.py | 29 +++++++++------------ 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 98ebf0904f..840f9af0ca 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -6,7 +6,7 @@ import re import shlex from pathlib import Path -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Literal, cast # third party imports try: @@ -141,11 +141,7 @@ def validate_inputs(self, provided_inputs: dict[str, str]) -> list[str]: return [] required_input_ids = self.get_required_inputs() - missing_inputs = [] - - for input_id in required_input_ids: - if input_id not in provided_inputs: - missing_inputs.append(input_id) + missing_inputs = [input_id for input_id in required_input_ids if input_id not in provided_inputs] return missing_inputs @@ -175,16 +171,15 @@ def replace_input(match: re.Match[str]) -> str: return re.sub(r"\$\{input:([^}]+)\}", replace_input, data) elif isinstance(data, dict): - result = {} # type: ignore - for k, v in data.items(): # type: ignore - result[k] = cls._substitute_inputs(v, inputs) # type: ignore - return result + dict_result: dict[str, Any] = {} + dict_data = cast(dict[str, Any], data) + for k, v in dict_data.items(): + dict_result[k] = cls._substitute_inputs(v, inputs) + return dict_result elif isinstance(data, list): - result = [] # type: ignore - for item in data: # type: ignore - result.append(cls._substitute_inputs(item, inputs)) # type: ignore - return result + list_data = cast(list[Any], data) + return [cls._substitute_inputs(item, inputs) for item in list_data] else: return data @@ -192,7 +187,7 @@ def replace_input(match: re.Match[str]) -> str: @classmethod def _strip_json_comments(cls, content: str) -> str: """Strip // comments from JSON content, being careful not to remove // inside strings.""" - result = [] + result: list[str] = [] lines = content.split("\n") for line in lines: @@ -266,12 +261,12 @@ def from_file( if inputs is not None and preliminary_config.inputs: missing_inputs = preliminary_config.validate_inputs(inputs) if missing_inputs: - descriptions = [] + descriptions: list[str] = [] for input_id in missing_inputs: desc = preliminary_config.get_input_description(input_id) descriptions.append(f" - {input_id}: {desc or 'No description'}") - raise ValueError(f"Missing required input values:\n" + "\n".join(descriptions)) + raise ValueError("Missing required input values:\n" + "\n".join(descriptions)) # Substitute input placeholders if inputs provided if inputs: From 70e91b9a6bd954cf4941273b44cda7a9d19a7997 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 15:49:17 -0700 Subject: [PATCH 28/42] Add tests for when yaml is not importable --- .../client/config/test_yaml_functionality.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/client/config/test_yaml_functionality.py b/tests/client/config/test_yaml_functionality.py index f9e39a01ca..c564ad619d 100644 --- a/tests/client/config/test_yaml_functionality.py +++ b/tests/client/config/test_yaml_functionality.py @@ -1,5 +1,6 @@ # stdlib imports from pathlib import Path +from unittest.mock import patch # third party imports import pytest @@ -109,3 +110,41 @@ def test_npx_filesystem_server(mcp_yaml_config_file: Path): "/Users/username/Desktop", "/path/to/other/allowed/dir", ] + + +def test_yaml_not_importable_error(mcp_yaml_config_file: Path): + """Test that trying to parse a YAML file when yaml module is not available raises ImportError.""" + + # Mock the yaml module to be None (simulating import failure) + with patch("mcp.client.config.mcp_servers_config.yaml", None): + with pytest.raises(ImportError, match="PyYAML is required to parse YAML files"): + MCPServersConfig.from_file(mcp_yaml_config_file) + + +def test_yaml_not_importable_error_with_use_pyyaml_true(): + """Test that trying to use use_pyyaml=True when yaml module is not available raises ImportError.""" + + # Create a simple JSON file content but force YAML parsing + json_file = Path(__file__).parent / "mcp.json" + + # Mock the yaml module to be None (simulating import failure) + with patch("mcp.client.config.mcp_servers_config.yaml", None): + with pytest.raises(ImportError, match="PyYAML is required to parse YAML files"): + MCPServersConfig.from_file(json_file, use_pyyaml=True) + + +def test_yaml_not_importable_error_with_yml_extension(tmp_path: Path): + """Test that trying to parse a .yml file when yaml module is not available raises ImportError.""" + + # Create a temporary .yml file + yml_file = tmp_path / "test_config.yml" + yml_file.write_text(""" +mcpServers: + test_server: + command: python -m test_server +""") + + # Mock the yaml module to be None (simulating import failure) + with patch("mcp.client.config.mcp_servers_config.yaml", None): + with pytest.raises(ImportError, match="PyYAML is required to parse YAML files"): + MCPServersConfig.from_file(yml_file) From cf2b2be432a8a2aed8464c81038caa07e6882b8d Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 15:49:58 -0700 Subject: [PATCH 29/42] Add support for name, description, isActive optional fields I found these in a 5ire `mcp.json` config file --- src/mcp/client/config/mcp_servers_config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 840f9af0ca..cc26968693 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -28,6 +28,10 @@ class InputDefinition(BaseModel): class MCPServerConfig(BaseModel): """Base class for MCP server configurations.""" + name: str | None = None + description: str | None = None + isActive: bool = True + def as_dict(self) -> dict[str, Any]: """Return the server configuration as a dictionary.""" return self.model_dump(exclude_none=True) From 6493c57666f7c6674f4164c3b7cac24fafb578d5 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 15:57:36 -0700 Subject: [PATCH 30/42] Smarter detection of SSE servers --- src/mcp/client/config/mcp_servers_config.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index cc26968693..946f9477dd 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -120,12 +120,20 @@ def infer_server_types(cls, servers_data: dict[str, Any]) -> dict[str, Any]: """Automatically infer server types when 'type' field is omitted.""" for server_config in servers_data.values(): - if isinstance(server_config, dict) and "type" not in server_config: + server_config = cast(dict[str, str], server_config) + sse_mentioned_in_config = ( + "sse" in server_config.get("url", "") + or "sse" in server_config.get("name", "") + or "sse" in server_config.get("description", "") + ) + if "type" not in server_config: # Infer type based on distinguishing fields if "command" in server_config: server_config["type"] = "stdio" - elif "url" in server_config: + elif "url" in server_config and sse_mentioned_in_config: # Could infer SSE vs streamable_http based on URL patterns in the future + server_config["type"] = "sse" + elif "url" in server_config: server_config["type"] = "streamable_http" return servers_data From 27c63e647a5894109e4b4267ba6bf457083ebd7a Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 16:11:55 -0700 Subject: [PATCH 31/42] Lowercase fields before checking for "sse" in them --- src/mcp/client/config/mcp_servers_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 946f9477dd..44824e4929 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -122,9 +122,9 @@ def infer_server_types(cls, servers_data: dict[str, Any]) -> dict[str, Any]: for server_config in servers_data.values(): server_config = cast(dict[str, str], server_config) sse_mentioned_in_config = ( - "sse" in server_config.get("url", "") - or "sse" in server_config.get("name", "") - or "sse" in server_config.get("description", "") + "sse" in server_config.get("url", "").lower() + or "sse" in server_config.get("name", "").lower() + or "sse" in server_config.get("description", "").lower() ) if "type" not in server_config: # Infer type based on distinguishing fields From d5aeca6c26bf70911b730aa03aaafde53a7ac60c Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 16:13:24 -0700 Subject: [PATCH 32/42] Remove as_dict method --- src/mcp/client/config/mcp_servers_config.py | 4 - .../client/config/test_mcp_servers_config.py | 86 +++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 44824e4929..5604b1eee5 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -32,10 +32,6 @@ class MCPServerConfig(BaseModel): description: str | None = None isActive: bool = True - def as_dict(self) -> dict[str, Any]: - """Return the server configuration as a dictionary.""" - return self.model_dump(exclude_none=True) - class StdioServerConfig(MCPServerConfig): """Configuration for stdio-based MCP servers.""" diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 4566a917fd..df06057f94 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -202,6 +202,7 @@ def test_input_substitution(): "command": "python -m ${input:module-name}", "args": ["--config", "${input:config-file}"], "env": {"API_KEY": "${input:api-key}", "ENV": "${input:environment}"}, + "isActive": True, }, } } @@ -415,6 +416,21 @@ def test_get_required_inputs_no_inputs_defined(): assert required_inputs == [] +def test_get_required_inputs_empty_inputs_list(): + """Test getting required inputs when inputs is explicitly set to an empty list.""" + config_data = { + "inputs": [], # Explicitly empty list + "servers": {"test_server": {"type": "stdio", "command": "python test.py"}} + } + + config = MCPServersConfig.model_validate(config_data) + required_inputs = config.get_required_inputs() + assert config.validate_inputs({}) == [] + + assert required_inputs == [] + assert config.inputs == [] # Verify inputs is actually an empty list, not None + + def test_validate_inputs_all_provided(): """Test input validation when all required inputs are provided.""" config_data = { @@ -743,3 +759,73 @@ def test_jsonc_multiline_strings_with_comments(): test2 = config.servers["test2"] assert isinstance(test2, SSEServerConfig) assert test2.url == "https://example.com" + + +def test_sse_type_inference(): + """Test that servers with 'url' field (and SSE mention) are inferred as sse type.""" + config_data = { + "servers": { + "api_server": { + "url": "https://api.example.com/sse" + # No explicit type - should be inferred as sse + # because "sse" is in the url + }, + "webhook_server": { + "url": "https://webhook.example.com/mcp/api", + "description": "A simple SSE server", + "headers": {"X-API-Key": "secret123"} + # No explicit type - should be inferred as sse + # because "SSE" is in the description + } + } + } + + config = MCPServersConfig.model_validate(config_data) + + # Verify first server + api_server = config.servers["api_server"] + assert isinstance(api_server, SSEServerConfig) + assert api_server.type == "sse" # Should be auto-inferred + assert api_server.url == "https://api.example.com/sse" + assert api_server.headers is None + + # Verify second server + webhook_server = config.servers["webhook_server"] + assert isinstance(webhook_server, SSEServerConfig) + assert webhook_server.type == "sse" # Should be auto-inferred + assert webhook_server.url == "https://webhook.example.com/mcp/api" + assert webhook_server.headers == {"X-API-Key": "secret123"} + + +def test_streamable_http_type_inference(): + """Test that servers with 'url' field (but no SSE mention) are inferred as streamable_http type.""" + config_data = { + "servers": { + "api_server": { + "url": "https://api.example.com/mcp" + # No explicit type - should be inferred as streamable_http + # No mention of 'sse' in url, name, or description + }, + "webhook_server": { + "url": "https://webhook.example.com/mcp/api", + "headers": {"X-API-Key": "secret123"} + # No explicit type - should be inferred as streamable_http + } + } + } + + config = MCPServersConfig.model_validate(config_data) + + # Verify first server + api_server = config.servers["api_server"] + assert isinstance(api_server, StreamableHTTPServerConfig) + assert api_server.type == "streamable_http" # Should be auto-inferred + assert api_server.url == "https://api.example.com/mcp" + assert api_server.headers is None + + # Verify second server + webhook_server = config.servers["webhook_server"] + assert isinstance(webhook_server, StreamableHTTPServerConfig) + assert webhook_server.type == "streamable_http" # Should be auto-inferred + assert webhook_server.url == "https://webhook.example.com/mcp/api" + assert webhook_server.headers == {"X-API-Key": "secret123"} From d6d380927cc6d874d7d9b81252d28515f9142fe0 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 16:17:01 -0700 Subject: [PATCH 33/42] ruff format tests/client/config/test_mcp_servers_config.py --- tests/client/config/test_mcp_servers_config.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index df06057f94..d8b420f6e2 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -420,7 +420,7 @@ def test_get_required_inputs_empty_inputs_list(): """Test getting required inputs when inputs is explicitly set to an empty list.""" config_data = { "inputs": [], # Explicitly empty list - "servers": {"test_server": {"type": "stdio", "command": "python test.py"}} + "servers": {"test_server": {"type": "stdio", "command": "python test.py"}}, } config = MCPServersConfig.model_validate(config_data) @@ -773,10 +773,10 @@ def test_sse_type_inference(): "webhook_server": { "url": "https://webhook.example.com/mcp/api", "description": "A simple SSE server", - "headers": {"X-API-Key": "secret123"} + "headers": {"X-API-Key": "secret123"}, # No explicit type - should be inferred as sse # because "SSE" is in the description - } + }, } } @@ -808,9 +808,9 @@ def test_streamable_http_type_inference(): }, "webhook_server": { "url": "https://webhook.example.com/mcp/api", - "headers": {"X-API-Key": "secret123"} + "headers": {"X-API-Key": "secret123"}, # No explicit type - should be inferred as streamable_http - } + }, } } From fbfa594af70e6570515e4617a2c0849447597707 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 16:58:06 -0700 Subject: [PATCH 34/42] Add docs --- README.md | 6 + docs/client-configuration.md | 300 ++++++++++++++++++++ mkdocs.yml | 1 + src/mcp/client/config/mcp_servers_config.py | 45 ++- 4 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 docs/client-configuration.md diff --git a/README.md b/README.md index d76d3d267f..f2e0c2534f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ - [Advanced Usage](#advanced-usage) - [Low-Level Server](#low-level-server) - [Writing MCP Clients](#writing-mcp-clients) + - [API for Client Configuration](#api-for-client-configuration) - [MCP Primitives](#mcp-primitives) - [Server Capabilities](#server-capabilities) - [Documentation](#documentation) @@ -862,6 +863,11 @@ async def main(): For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). +### API for Client Configuration + +The MCP Python SDK provides an API for client configuration. This lets client applications easily load MCP servers from configuration files in a variety of formats and with some useful, built-in features. + +See the [Client Configuration Guide](docs/client-configuration.md) for complete details. ### MCP Primitives diff --git a/docs/client-configuration.md b/docs/client-configuration.md new file mode 100644 index 0000000000..d7ba495677 --- /dev/null +++ b/docs/client-configuration.md @@ -0,0 +1,300 @@ +# MCP Client Configuration (NEW) + +This guide covers how to configure MCP servers for client applications, +such as Claude Desktop, Cursor, and VS Code. + +## Configuration File Formats + +MCP supports multiple configuration file formats for maximum flexibility. + +### JSON Configuration + +```json +{ + "mcpServers": { + "time": { + "command": "uvx", + "args": ["mcp-server-time"] + }, + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/username/Desktop"] + } + } +} +``` + +This is a typical JSON configuration file for an MCP server in that it has +`command` and `args` (as a list) fields. + +Also supported is to specify the entire command in the `command` field (easier +to write and read). In this case, the library will automatically split the +command into `command` and `args` fields internally. + +```json +{ + "mcpServers": { + "time": { + "command": "uvx mcp-server-time" + }, + "filesystem": { + "command": "npx -y @modelcontextprotocol/server-filesystem /Users/username/Desktop" + } + } +} +``` + +JSON is the most commonly used format for MCP servers, but it has some +limitations, which is why subsequent sections cover other formats, such as JSONC +and YAML. + +### JSON with Comments (JSONC) + +For better maintainability, MCP supports JSON files with `//` comments: + +```jsonc +{ + "mcpServers": { + // Can get current time in various timezones + "time": { + "command": "uvx mcp-server-time" + }, + + // Can get the contents of the user's desktop + "filesystem": { + "command": "npx -y @modelcontextprotocol/server-filesystem /Users/username/Desktop" + } + } +} +``` + +### YAML Configuration + +YAML configuration files are supported for improved readability: + +```yaml +mcpServers: + # Can get current time in various timezones + time: + command: uvx mcp-server-time + + # Can get the contents of the user's desktop + filesystem: + command: npx -y @modelcontextprotocol/server-filesystem /Users/username/Desktop +``` + +**Installation**: YAML support requires the optional dependency: + +```bash +pip install "mcp[yaml]" +``` + +## Server Types and Auto-Detection + +MCP automatically infers server types based on configuration fields when the +`type` field is omitted: + +### Stdio Servers + +Servers with a `command` field are automatically detected as `stdio` type: + +```yaml +mcpServers: + python-server: + command: python -m my_server + # type: stdio (auto-inferred) +``` + +### Streamable HTTP Servers + +Servers with a `url` field (without SSE keywords) are detected as `streamable_http` type: + +```yaml +mcpServers: + api-server: + url: https://api.example.com/mcp + # type: streamable_http (auto-inferred) +``` + +### SSE Servers + +Servers with a `url` field containing "sse" in the URL, name, or description are detected as `sse` type: + +```yaml +mcpServers: + sse-server: + url: https://api.example.com/sse + # type: sse (auto-inferred due to "sse" in URL) + + event-server: + url: https://api.example.com/events + description: "SSE-based event server" + # type: sse (auto-inferred due to "SSE" in description) +``` + +## Input Variables and Substitution + +MCP supports dynamic configuration using input variables, which is a feature +that VS Code supports. This works in both JSON and YAML configurations. + +### Defining Inputs + +```yaml +inputs: + - id: api-key + type: promptString + description: "Your API key" + password: true + - id: server-host + type: promptString + description: "Server hostname" + +mcpServers: + dynamic-server: + url: https://${input:server-host}/mcp + headers: + Authorization: Bearer ${input:api-key} +``` + +### Using Inputs + +When loading the configuration, provide input values: + +```python +from mcp.client.config.mcp_servers_config import MCPServersConfig + +# Load with input substitution +config = MCPServersConfig.from_file( + "config.yaml", + inputs={ + "api-key": "secret-key-123", + "server-host": "api.example.com" + } +) +``` + +### Input Validation + +MCP validates that all required inputs are provided: + +```python +# Check required inputs +required_inputs = config.get_required_inputs() +print(f"Required inputs: {required_inputs}") + +# Validate provided inputs +missing_inputs = config.validate_inputs(provided_inputs) +if missing_inputs: + print(f"Missing required inputs: {missing_inputs}") +``` + +## Configuration Schema + +### Server Configuration Base Fields + +All server types support these common fields: + +- `name` (string, optional): Display name for the server +- `description` (string, optional): Server description +- `isActive` (boolean, default: true): Whether the server is active + +### Stdio Server Configuration + +```yaml +mcpServers: + stdio-server: + type: stdio # Optional if 'command' is present + command: python -m my_server + args: # Optional additional arguments + - --debug + - --port=8080 + env: # Optional environment variables + DEBUG: "true" + API_KEY: secret123 +``` + +### Streamable HTTP Server Configuration + +```yaml +mcpServers: + http-server: + type: streamable_http # Optional if 'url' is present + url: https://api.example.com/mcp + headers: # Optional HTTP headers + Authorization: Bearer token123 + X-Custom-Header: value +``` + +### SSE Server Configuration + +```yaml +mcpServers: + sse-server: + type: sse + url: https://api.example.com/sse + headers: # Optional HTTP headers + Authorization: Bearer token123 +``` + +## Field Aliases + +MCP supports both traditional and modern field names: + +- `mcpServers` (most common) or `servers` (VS Code) + +```yaml +# More common format +mcpServers: + my-server: + command: python -m server + +# VS Code format (equivalent) +servers: + my-server: + command: python -m server +``` + +## Loading Configuration Files + +```python +from mcp.client.config.mcp_servers_config import MCPServersConfig +from pathlib import Path + +# Load JSON +config = MCPServersConfig.from_file("config.json") + +# Load YAML (auto-detected by extension) +config = MCPServersConfig.from_file("config.yaml") + +# Force YAML parsing +config = MCPServersConfig.from_file("config.json", use_pyyaml=True) + +# Load with input substitution +config = MCPServersConfig.from_file( + "config.yaml", + inputs={"api-key": "secret"} +) +``` + +## Error Handling + +### Missing YAML Dependency + +```python +try: + config = MCPServersConfig.from_file("config.yaml") +except ImportError as e: + print("Install YAML support: pip install 'mcp[yaml]'") +``` + +### Missing Input Values + +```python +try: + config = MCPServersConfig.from_file("config.yaml", inputs={}) +except ValueError as e: + print(f"Configuration error: {e}") + # Error: Missing required input values: + # - api-key: Your API key + # - server-host: Server hostname +``` diff --git a/mkdocs.yml b/mkdocs.yml index b907cb8737..e17e64b610 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ site_url: https://modelcontextprotocol.github.io/python-sdk nav: - Home: index.md + - Client Configuration: client-configuration.md - API Reference: api.md theme: diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 5604b1eee5..47240f7958 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -1,4 +1,47 @@ -"""Configuration management for MCP servers.""" +"""Configuration management for MCP servers. + +This module provides comprehensive configuration management for MCP (Model +Context Protocol) servers, supporting multiple file formats and advanced +features: + +Features: + +- Multiple file formats: JSON, JSONC (JSON with comments), and YAML (.yaml/.yml) + +- Automatic server type inference based on configuration fields + +- Input variable substitution with ${input:key} syntax and validation of + required inputs, borrowed from VS Code + +- Support for both 'mcpServers' and 'servers' (VS Code) field names + +Supported Server Types: + +- streamable_http: HTTP-based servers using streamable transport + +- stdio: Servers that communicate via standard input/output + +- sse: Server-Sent Events based servers + +Example usage: + + # Load basic configuration + config = MCPServersConfig.from_file("config.json") + + # Load YAML with input substitution + config = MCPServersConfig.from_file( + "config.yaml", + inputs={"api-key": "secret", "host": "api.example.com"} + ) + + # Validate inputs + missing = config.validate_inputs(provided_inputs) + if missing: + raise ValueError(f"Missing inputs: {missing}") + +Dependencies: +- PyYAML: Required for YAML file support (install with 'mcp[yaml]') +""" # stdlib imports import json From eec3c2b3f8ddde54abce298d88b61e1799a07451 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 17:36:51 -0700 Subject: [PATCH 35/42] Documentation tweaks --- docs/client-configuration.md | 126 +++++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 34 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index d7ba495677..b5fc7801e0 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -1,7 +1,39 @@ # MCP Client Configuration (NEW) -This guide covers how to configure MCP servers for client applications, -such as Claude Desktop, Cursor, and VS Code. +This guide, for client application developers, covers a new API for client +configuration. Client applications can use this API to get info about configured +MCP servers from configuration files in a variety of formats and with some +useful, built-in features. + +## Loading Configuration Files + +```python +from mcp.client.config.mcp_servers_config import MCPServersConfig + +# Load JSON +config = MCPServersConfig.from_file("~/.cursor/mcp.json") +config = MCPServersConfig.from_file("~/Library/Application\ Support/Claude/claude_desktop_config.json") + +# Load YAML (auto-detected by extension) +config = MCPServersConfig.from_file("~/.cursor/mcp.yaml") # Not yet support in Cursor but maybe soon...?! +config = MCPServersConfig.from_file("~/Library/Application\ Support/Claude/claude_desktop_config.yaml") # Maybe someday...?! + +# Load with input substitution +config = MCPServersConfig.from_file( + ".vscode/mcp.json", + inputs={"api-key": "secret"} +) + +mcp_server = config.servers["time"] +print(mcp_server.command) +print(mcp_server.args) +print(mcp_server.env) +print(mcp_server.headers) +print(mcp_server.inputs) +print(mcp_server.isActive) +print(mcp_server.effective_command) +print(mcp_server.effective_args) +``` ## Configuration File Formats @@ -27,9 +59,10 @@ MCP supports multiple configuration file formats for maximum flexibility. This is a typical JSON configuration file for an MCP server in that it has `command` and `args` (as a list) fields. -Also supported is to specify the entire command in the `command` field (easier -to write and read). In this case, the library will automatically split the -command into `command` and `args` fields internally. +Users can also specify the entire command in the `command` field, which +makes it easier to read and write. Internally, the library splits the command +into `command` and `args` fields, so the result is a nicer user experience and +no application code needs to change. ```json { @@ -50,7 +83,8 @@ and YAML. ### JSON with Comments (JSONC) -For better maintainability, MCP supports JSON files with `//` comments: +The API supports JSON files with `//` comments (JSONC), which is very commonly +used in the VS Code ecosystem: ```jsonc { @@ -70,7 +104,9 @@ For better maintainability, MCP supports JSON files with `//` comments: ### YAML Configuration -YAML configuration files are supported for improved readability: +The API supports YAML configuration files, which offer improved readability, +comments, and the ability to completely sidestep issues with commas, that are +common when working with JSON. ```yaml mcpServers: @@ -107,7 +143,8 @@ mcpServers: ### Streamable HTTP Servers -Servers with a `url` field (without SSE keywords) are detected as `streamable_http` type: +Servers with a `url` field (without SSE keywords) are detected as +`streamable_http` type: ```yaml mcpServers: @@ -118,7 +155,8 @@ mcpServers: ### SSE Servers -Servers with a `url` field containing "sse" in the URL, name, or description are detected as `sse` type: +Servers with a `url` field containing "sse" in the URL, name, or description are +detected as `sse` type: ```yaml mcpServers: @@ -137,7 +175,35 @@ mcpServers: MCP supports dynamic configuration using input variables, which is a feature that VS Code supports. This works in both JSON and YAML configurations. -### Defining Inputs +### Declaring Inputs (JSON) + +```json +{ + "inputs": [ + { + "id": "api-key", + "type": "promptString", + "description": "Your API key", + "password": true + }, + { + "id": "server-host", + "type": "promptString", + "description": "Server hostname" + } + ], + "servers": { + "dynamic-server": { + "url": "https://${input:server-host}/mcp", + "headers": { + "Authorization": "Bearer ${input:api-key}" + } + } + } +} +``` + +### Declaring Inputs (YAML) ```yaml inputs: @@ -149,13 +215,27 @@ inputs: type: promptString description: "Server hostname" -mcpServers: +servers: dynamic-server: url: https://${input:server-host}/mcp headers: Authorization: Bearer ${input:api-key} ``` +### Getting DeclaredInputs + +The application can use the `inputs` field to get the declared inputs and +prompt the user for the values or otherwise allow them to be specified. + +The application gets the declared inputs by doing: + +```python +config = MCPServersConfig.from_file(".vscode/mcp.json") +for input in config.inputs: + # Prompt the user for the value + ... +``` + ### Using Inputs When loading the configuration, provide input values: @@ -192,7 +272,7 @@ if missing_inputs: ### Server Configuration Base Fields -All server types support these common fields: +All server types support these common optionalfields: - `name` (string, optional): Display name for the server - `description` (string, optional): Server description @@ -254,28 +334,6 @@ servers: command: python -m server ``` -## Loading Configuration Files - -```python -from mcp.client.config.mcp_servers_config import MCPServersConfig -from pathlib import Path - -# Load JSON -config = MCPServersConfig.from_file("config.json") - -# Load YAML (auto-detected by extension) -config = MCPServersConfig.from_file("config.yaml") - -# Force YAML parsing -config = MCPServersConfig.from_file("config.json", use_pyyaml=True) - -# Load with input substitution -config = MCPServersConfig.from_file( - "config.yaml", - inputs={"api-key": "secret"} -) -``` - ## Error Handling ### Missing YAML Dependency From 31eb3e1541924b4d4aeec01d7f9387abe97914a9 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 17:52:56 -0700 Subject: [PATCH 36/42] Docs tweaks --- docs/client-configuration.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index b5fc7801e0..8f8e5a7fb4 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -2,8 +2,25 @@ This guide, for client application developers, covers a new API for client configuration. Client applications can use this API to get info about configured -MCP servers from configuration files in a variety of formats and with some -useful, built-in features. +MCP servers from configuration files + +## Why should my application use this API? + +- Eliminate the need to write and maintain code to parse configuration files +- Your application can easily benefit from bug fixes and new features related to configuration +- Allows your application to support features that other applications may have + and which your application does not. E.g., + + - Allow specifying the entire command in the `command` field (not having to + specify an `args` list), which makes it easier for users to manage + - Allow comments in JSON configuration files + - Input variables (as supported by VS Code), plus validation of required inputs + and interpolation of input values + - YAML configuration files, which are more readable and easier to write than JSON + +- If every application that uses MCP supported this API, it would lead to + greater consistency in how MCP servers are configured and used, which is a + tremendous win for users and a benefit to the MCP ecosystem. ## Loading Configuration Files From 9753da80fe646ed3765f2b43cb1e6b37e71ad1cc Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 17:56:03 -0700 Subject: [PATCH 37/42] Docs tweaks --- docs/client-configuration.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 8f8e5a7fb4..4f6ffab563 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -20,8 +20,9 @@ MCP servers from configuration files - If every application that uses MCP supported this API, it would lead to greater consistency in how MCP servers are configured and used, which is a - tremendous win for users and a benefit to the MCP ecosystem. - + tremendous win for users and a benefit to the MCP ecosystem. Note: This is the + Python SDK, but hopefully this can be ported to the SDKs for other languages. + ## Loading Configuration Files ```python @@ -32,7 +33,7 @@ config = MCPServersConfig.from_file("~/.cursor/mcp.json") config = MCPServersConfig.from_file("~/Library/Application\ Support/Claude/claude_desktop_config.json") # Load YAML (auto-detected by extension) -config = MCPServersConfig.from_file("~/.cursor/mcp.yaml") # Not yet support in Cursor but maybe soon...?! +config = MCPServersConfig.from_file("~/.cursor/mcp.yaml") # Not yet supported in Cursor but maybe soon...?! config = MCPServersConfig.from_file("~/Library/Application\ Support/Claude/claude_desktop_config.yaml") # Maybe someday...?! # Load with input substitution From 02fc66fcfaf5b21827be8525c5bbf28e09a6c2ac Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 17:58:36 -0700 Subject: [PATCH 38/42] Docs tweaks --- docs/client-configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 4f6ffab563..21dcd68a81 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -240,7 +240,7 @@ servers: Authorization: Bearer ${input:api-key} ``` -### Getting DeclaredInputs +### Getting Declared Inputs The application can use the `inputs` field to get the declared inputs and prompt the user for the values or otherwise allow them to be specified. From 47e92d7a44c0ce3b6b85d874cb0865d0ba1282dc Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 20:02:46 -0700 Subject: [PATCH 39/42] Move input substitution to new `server` method It seemed weird doing it in `from_file`, because the caller has to call `from_file` to find out what the required inputs are and then has to call `from_file` against to provide the input values. Now, the caller calls config.get(server, input_values={...}) to provide the input values --- src/mcp/client/config/mcp_servers_config.py | 47 +++-- .../client/config/test_mcp_servers_config.py | 187 +++++++----------- 2 files changed, 99 insertions(+), 135 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index 47240f7958..dc043815e6 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -138,6 +138,31 @@ class MCPServersConfig(BaseModel): servers: dict[str, ServerConfigUnion] inputs: list[InputDefinition] | None = None + def server( + self, + name: str, + input_values: dict[str, str] | None = None, + ) -> ServerConfigUnion: + """Get a server config by name.""" + server = self.servers[name] + + # Validate inputs if provided and input definitions exist + if input_values is not None and self.inputs: + missing_inputs = self.validate_inputs(input_values) + if missing_inputs: + descriptions: list[str] = [] + for input_id in missing_inputs: + desc = self.get_input_description(input_id) + descriptions.append(f" - {input_id}: {desc or 'No description'}") + + raise ValueError("Missing required input values:\n" + "\n".join(descriptions)) + + # Substitute input placeholders if inputs provided + if input_values: + server.__dict__ = self._substitute_inputs(server.__dict__, input_values) + + return server + @model_validator(mode="before") @classmethod def handle_field_aliases(cls, data: dict[str, Any]) -> dict[str, Any]: @@ -275,7 +300,9 @@ def _strip_json_comments(cls, content: str) -> str: @classmethod def from_file( - cls, config_path: Path | str, use_pyyaml: bool = False, inputs: dict[str, str] | None = None + cls, + config_path: Path | str, + use_pyyaml: bool = False, ) -> "MCPServersConfig": """Load configuration from a JSON or YAML file. @@ -305,22 +332,4 @@ def from_file( cleaned_content = cls._strip_json_comments(content) data = json.loads(cleaned_content) - # Create a preliminary config to validate inputs if they're defined - preliminary_config = cls.model_validate(data) - - # Validate inputs if provided and input definitions exist - if inputs is not None and preliminary_config.inputs: - missing_inputs = preliminary_config.validate_inputs(inputs) - if missing_inputs: - descriptions: list[str] = [] - for input_id in missing_inputs: - desc = preliminary_config.get_input_description(input_id) - descriptions.append(f" - {input_id}: {desc or 'No description'}") - - raise ValueError("Missing required input values:\n" + "\n".join(descriptions)) - - # Substitute input placeholders if inputs provided - if inputs: - data = cls._substitute_inputs(data, inputs) - return cls.model_validate(data) diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index d8b420f6e2..975bd964b6 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -188,108 +188,39 @@ def test_servers_field_takes_precedence(): assert len(config.servers) == 1 -def test_input_substitution(): - """Test that ${input:key} placeholders are substituted correctly.""" - config_data = { - "servers": { - "azure_server": { - "type": "sse", - "url": "https://${input:app-name}.azurewebsites.net/mcp/sse", - "headers": {"Authorization": "Bearer ${input:api-token}", "X-Custom-Header": "${input:custom-value}"}, - }, - "stdio_server": { - "type": "stdio", - "command": "python -m ${input:module-name}", - "args": ["--config", "${input:config-file}"], - "env": {"API_KEY": "${input:api-key}", "ENV": "${input:environment}"}, - "isActive": True, - }, - } - } - - inputs = { - "app-name": "my-function-app", - "api-token": "abc123token", - "custom-value": "custom-header-value", - "module-name": "my_server", - "config-file": "/path/to/config.json", - "api-key": "secret-api-key", - "environment": "production", - } - - # Test substitution - substituted = MCPServersConfig._substitute_inputs(config_data, inputs) - config = MCPServersConfig.model_validate(substituted) - - # Test SSE server substitution - sse_server = config.servers["azure_server"] - assert isinstance(sse_server, SSEServerConfig) - assert sse_server.url == "https://my-function-app.azurewebsites.net/mcp/sse" - assert sse_server.headers == {"Authorization": "Bearer abc123token", "X-Custom-Header": "custom-header-value"} - - # Test stdio server substitution - stdio_server = config.servers["stdio_server"] - assert isinstance(stdio_server, StdioServerConfig) - assert stdio_server.command == "python -m my_server" - assert stdio_server.args == ["--config", "/path/to/config.json"] - assert stdio_server.env == {"API_KEY": "secret-api-key", "ENV": "production"} - - -def test_input_substitution_missing_key(): - """Test that missing input keys raise appropriate errors.""" - config_data = {"servers": {"test_server": {"type": "sse", "url": "https://${input:missing-key}.example.com"}}} - - inputs = {"other-key": "value"} - - with pytest.raises(ValueError, match="Missing input value for key: 'missing-key'"): - MCPServersConfig._substitute_inputs(config_data, inputs) - - -def test_input_substitution_partial(): - """Test that only specified placeholders are substituted.""" - config_data = { - "servers": { - "test_server": { - "type": "sse", - "url": "https://${input:app-name}.example.com/api/${input:version}", - "headers": {"Static-Header": "static-value", "Dynamic-Header": "${input:token}"}, - } - } - } - - inputs = { - "app-name": "myapp", - "token": "secret123", - # Note: 'version' is intentionally missing - } - - with pytest.raises(ValueError, match="Missing input value for key: 'version'"): - MCPServersConfig._substitute_inputs(config_data, inputs) - - def test_from_file_with_inputs(tmp_path: Path): """Test loading config from file with input substitution.""" # Create test config file config_content = { + "inputs": [ + {"id": "host", "description": "Server hostname"}, + {"id": "token", "description": "API token"}, + ], "servers": { "dynamic_server": { "type": "streamable_http", "url": "https://${input:host}/mcp/api", "headers": {"Authorization": "Bearer ${input:token}"}, } - } + }, } config_file = tmp_path / "test_config.json" with open(config_file, "w") as f: json.dump(config_content, f) - inputs = {"host": "api.example.com", "token": "test-token-123"} + config = MCPServersConfig.from_file(config_file) + + assert config.get_required_inputs() == ["host", "token"] + assert config.inputs is not None + assert config.inputs[0].id == "host" + assert config.inputs[1].id == "token" + assert config.inputs[0].description == "Server hostname" + assert config.inputs[1].description == "API token" - # Load with input substitution - config = MCPServersConfig.from_file(config_file, inputs=inputs) + input_values = {"host": "api.example.com", "token": "test-token-123"} + server = config.server("dynamic_server", input_values=input_values) - server = config.servers["dynamic_server"] assert isinstance(server, StreamableHTTPServerConfig) assert server.url == "https://api.example.com/mcp/api" assert server.headers == {"Authorization": "Bearer test-token-123"} @@ -324,6 +255,16 @@ def test_from_file_without_inputs(tmp_path: Path): def test_input_substitution_yaml_file(tmp_path: Path): """Test input substitution with YAML files.""" yaml_content = """ +inputs: + - type: promptString + id: module + description: Python module to run + - type: promptString + id: port + description: Port to run the server on + - type: promptString + id: debug + description: Debug mode servers: yaml_server: type: stdio @@ -336,13 +277,23 @@ def test_input_substitution_yaml_file(tmp_path: Path): """ config_file = tmp_path / "test_config.yaml" - config_file.write_text(yaml_content) + assert config_file.write_text(yaml_content) + + config = MCPServersConfig.from_file(config_file) - inputs = {"module": "test_server", "port": "8080", "debug": "true"} + assert config.get_required_inputs() == ["module", "port", "debug"] + assert config.inputs is not None + assert len(config.inputs) == 3 + assert config.inputs[0].id == "module" + assert config.inputs[0].description == "Python module to run" + assert config.inputs[1].id == "port" + assert config.inputs[1].description == "Port to run the server on" + assert config.inputs[2].id == "debug" + assert config.inputs[2].description == "Debug mode" - config = MCPServersConfig.from_file(config_file, inputs=inputs) + input_values = {"module": "test_server", "port": "8080", "debug": "true"} + server = config.server("yaml_server", input_values=input_values) - server = config.servers["yaml_server"] assert isinstance(server, StdioServerConfig) assert server.command == "python -m test_server" assert server.args == ["--port", "8080"] @@ -373,15 +324,14 @@ def test_input_definitions_parsing(): config = MCPServersConfig.model_validate(config_data) # Test input definitions are parsed correctly + assert config.get_required_inputs() == ["functionapp-name", "api-token"] assert config.inputs is not None assert len(config.inputs) == 2 - app_name_input = config.inputs[0] assert app_name_input.id == "functionapp-name" assert app_name_input.description == "Azure Functions App Name" assert app_name_input.password is False assert app_name_input.type == "promptString" - api_token_input = config.inputs[1] assert api_token_input.id == "api-token" assert api_token_input.description == "API Token for authentication" @@ -401,9 +351,8 @@ def test_get_required_inputs(): } config = MCPServersConfig.model_validate(config_data) - required_inputs = config.get_required_inputs() - assert required_inputs == ["input1", "input2", "input3"] + assert config.get_required_inputs() == ["input1", "input2", "input3"] def test_get_required_inputs_no_inputs_defined(): @@ -411,9 +360,8 @@ def test_get_required_inputs_no_inputs_defined(): config_data = {"servers": {"test_server": {"type": "stdio", "command": "python test.py"}}} config = MCPServersConfig.model_validate(config_data) - required_inputs = config.get_required_inputs() - assert required_inputs == [] + assert config.get_required_inputs() == [] def test_get_required_inputs_empty_inputs_list(): @@ -424,10 +372,9 @@ def test_get_required_inputs_empty_inputs_list(): } config = MCPServersConfig.model_validate(config_data) - required_inputs = config.get_required_inputs() - assert config.validate_inputs({}) == [] - assert required_inputs == [] + assert config.validate_inputs({}) == [] + assert config.get_required_inputs() == [] assert config.inputs == [] # Verify inputs is actually an empty list, not None @@ -513,12 +460,19 @@ def test_from_file_with_input_validation_success(tmp_path: Path): with open(config_file, "w") as f: json.dump(config_content, f) - inputs = {"app-name": "myapp", "env": "prod"} + config = MCPServersConfig.from_file(config_file) + + assert config.get_required_inputs() == ["app-name", "env"] + assert config.inputs is not None + assert len(config.inputs) == 2 + assert config.inputs[0].id == "app-name" + assert config.inputs[0].description == "Application name" + assert config.inputs[1].id == "env" + assert config.inputs[1].description == "Environment (dev/prod)" - # Should load successfully with all required inputs provided - config = MCPServersConfig.from_file(config_file, inputs=inputs) + input_values = {"app-name": "myapp", "env": "prod"} + server = config.server("app_server", input_values=input_values) - server = config.servers["app_server"] assert isinstance(server, StreamableHTTPServerConfig) assert server.url == "https://myapp-prod.example.com/mcp/api" @@ -537,13 +491,15 @@ def test_from_file_with_input_validation_failure(tmp_path: Path): with open(config_file, "w") as f: json.dump(config_content, f) - inputs = { + inputs: dict[str, str] = { # Missing 'required-key' and 'optional-host' } # Should raise ValueError with helpful error message with pytest.raises(ValueError, match="Missing required input values"): - MCPServersConfig.from_file(config_file, inputs=inputs) + config = MCPServersConfig.from_file(config_file) + server = config.server("test_server", input_values=inputs) + assert server def test_from_file_without_input_definitions_no_validation(tmp_path: Path): @@ -556,10 +512,11 @@ def test_from_file_without_input_definitions_no_validation(tmp_path: Path): with open(config_file, "w") as f: json.dump(config_content, f) + config = MCPServersConfig.from_file(config_file) + # Even with empty inputs, should load fine since no input definitions exist - config = MCPServersConfig.from_file(config_file, inputs={}) + server = config.server("test_server", input_values={}) - server = config.servers["test_server"] assert isinstance(server, StdioServerConfig) # Placeholder should remain unchanged assert server.command == "python -m server --token ${input:token}" @@ -586,20 +543,20 @@ def test_input_definition_with_yaml_file(tmp_path: Path): """ config_file = tmp_path / "test_config.yaml" - config_file.write_text(yaml_content) - - inputs = {"module-name": "test_module", "config-path": "/etc/config.json"} + assert config_file.write_text(yaml_content) - config = MCPServersConfig.from_file(config_file, inputs=inputs) + config = MCPServersConfig.from_file(config_file) # Verify input definitions were parsed + assert config.get_required_inputs() == ["module-name", "config-path"] assert config.inputs is not None assert len(config.inputs) == 2 assert config.inputs[0].id == "module-name" assert config.inputs[1].id == "config-path" - # Verify substitution worked - server = config.servers["yaml_server"] + input_values = {"module-name": "test_module", "config-path": "/etc/config.json"} + server = config.server("yaml_server", input_values=input_values) + assert isinstance(server, StdioServerConfig) assert server.command == "python -m test_module" assert server.args == ["--config", "/etc/config.json"] @@ -711,12 +668,10 @@ def test_from_file_with_jsonc_comments(tmp_path: Path): """ config_file = tmp_path / "test_config.json" - config_file.write_text(jsonc_content) - - inputs = {"api-key": "secret123"} + assert config_file.write_text(jsonc_content) # Should load successfully despite comments - config = MCPServersConfig.from_file(config_file, inputs=inputs) + config = MCPServersConfig.from_file(config_file) # Verify input definitions were parsed assert config.inputs is not None @@ -724,7 +679,7 @@ def test_from_file_with_jsonc_comments(tmp_path: Path): assert config.inputs[0].id == "api-key" # Verify server configuration and input substitution - server = config.servers["main_server"] + server = config.server("main_server", input_values={"api-key": "secret123"}) assert isinstance(server, SSEServerConfig) assert server.url == "https://api.example.com/mcp/sse" assert server.headers == {"Authorization": "Bearer secret123"} From 71e20d1680a02f1aedbfbf64df70ef05589a7821 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 21:03:38 -0700 Subject: [PATCH 40/42] Doc updates --- docs/client-configuration.md | 44 +++++++----------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 21dcd68a81..c01d08ce04 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -36,13 +36,9 @@ config = MCPServersConfig.from_file("~/Library/Application\ Support/Claude/claud config = MCPServersConfig.from_file("~/.cursor/mcp.yaml") # Not yet supported in Cursor but maybe soon...?! config = MCPServersConfig.from_file("~/Library/Application\ Support/Claude/claude_desktop_config.yaml") # Maybe someday...?! -# Load with input substitution -config = MCPServersConfig.from_file( - ".vscode/mcp.json", - inputs={"api-key": "secret"} -) +config = MCPServersConfig.from_file(".vscode/mcp.json") -mcp_server = config.servers["time"] +mcp_server = config.server("time") print(mcp_server.command) print(mcp_server.args) print(mcp_server.env) @@ -261,13 +257,12 @@ When loading the configuration, provide input values: ```python from mcp.client.config.mcp_servers_config import MCPServersConfig -# Load with input substitution -config = MCPServersConfig.from_file( - "config.yaml", - inputs={ - "api-key": "secret-key-123", - "server-host": "api.example.com" - } +config = MCPServersConfig.from_file("config.yaml") + +# Substitute input values into the configuration +server = config.server( + "dynamic-server", + input_values={"api-key": "secret-key-123", "server-host": "api.example.com"}, ) ``` @@ -351,26 +346,3 @@ servers: my-server: command: python -m server ``` - -## Error Handling - -### Missing YAML Dependency - -```python -try: - config = MCPServersConfig.from_file("config.yaml") -except ImportError as e: - print("Install YAML support: pip install 'mcp[yaml]'") -``` - -### Missing Input Values - -```python -try: - config = MCPServersConfig.from_file("config.yaml", inputs={}) -except ValueError as e: - print(f"Configuration error: {e}") - # Error: Missing required input values: - # - api-key: Your API key - # - server-host: Server hostname -``` From 0c3bde399791c9a074310a7993e9db74729914e5 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 23:23:34 -0700 Subject: [PATCH 41/42] Emit warning when `servers` attribute accessed --- src/mcp/client/config/mcp_servers_config.py | 49 ++++++++++++++++++- .../client/config/test_mcp_servers_config.py | 16 +++--- .../client/config/test_yaml_functionality.py | 28 +++++------ 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/mcp/client/config/mcp_servers_config.py b/src/mcp/client/config/mcp_servers_config.py index dc043815e6..de1a172a66 100644 --- a/src/mcp/client/config/mcp_servers_config.py +++ b/src/mcp/client/config/mcp_servers_config.py @@ -133,11 +133,50 @@ class SSEServerConfig(MCPServerConfig): class MCPServersConfig(BaseModel): - """Configuration for multiple MCP servers.""" + """Configuration for multiple MCP servers. + + Note: + Direct access to the 'servers' field is discouraged. + Use the server() method instead for proper input validation and substitution. + """ servers: dict[str, ServerConfigUnion] inputs: list[InputDefinition] | None = None + def __getattribute__(self, name: str) -> Any: + """Get an attribute from the config. + + This emits a warning if the `servers` field is accessed directly. + This is to discourage direct access to the `servers` field. + Use the `server()` method instead for proper input validation and substitution. + """ + + if name == "servers": + import inspect + import warnings + + # Get the calling frame to check if it's internal access + frame = inspect.currentframe() + if frame and frame.f_back: + caller_filename = frame.f_back.f_code.co_filename + caller_function = frame.f_back.f_code.co_name + + # Don't warn for internal methods, tests, or if called from within this class + is_internal_call = ( + caller_function in ("server", "list_servers", "has_server", "__init__", "model_validate") + or "mcp_servers_config.py" in caller_filename + ) + + if not is_internal_call: + warnings.warn( + f"Direct access to 'servers' field of {self.__class__.__name__} is discouraged. " + + "Use server() method instead for proper input validation and substitution.", + UserWarning, + stacklevel=2, + ) + + return super().__getattribute__(name) + def server( self, name: str, @@ -163,6 +202,14 @@ def server( return server + def list_servers(self) -> list[str]: + """Get a list of available server names.""" + return list(self.servers.keys()) + + def has_server(self, name: str) -> bool: + """Check if a server with the given name exists.""" + return name in self.servers + @model_validator(mode="before") @classmethod def handle_field_aliases(cls, data: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/client/config/test_mcp_servers_config.py b/tests/client/config/test_mcp_servers_config.py index 975bd964b6..8250e7ff80 100644 --- a/tests/client/config/test_mcp_servers_config.py +++ b/tests/client/config/test_mcp_servers_config.py @@ -183,9 +183,9 @@ def test_servers_field_takes_precedence(): config = MCPServersConfig.model_validate(config_data) # Should only have the 'servers' content, not 'mcpServers' - assert "new_server" in config.servers - assert "old_server" not in config.servers - assert len(config.servers) == 1 + assert config.has_server("new_server") + assert not config.has_server("old_server") + assert len(config.list_servers()) == 1 def test_from_file_with_inputs(tmp_path: Path): @@ -582,8 +582,8 @@ def test_jsonc_comment_stripping(): stripped = MCPServersConfig._strip_json_comments(content_with_comments) config = MCPServersConfig.model_validate(json.loads(stripped)) - assert "test_server" in config.servers - server = config.servers["test_server"] + assert config.has_server("test_server") + server = config.server("test_server") assert isinstance(server, StdioServerConfig) assert server.command == "python test.py" @@ -704,10 +704,10 @@ def test_jsonc_multiline_strings_with_comments(): config = MCPServersConfig.model_validate(json.loads(stripped)) assert len(config.servers) == 2 - assert "test1" in config.servers - assert "test2" in config.servers + assert config.has_server("test1") + assert config.has_server("test2") - test1 = config.servers["test1"] + test1 = config.server("test1") assert isinstance(test1, StdioServerConfig) assert test1.command == "python server.py" diff --git a/tests/client/config/test_yaml_functionality.py b/tests/client/config/test_yaml_functionality.py index c564ad619d..01012ecb4b 100644 --- a/tests/client/config/test_yaml_functionality.py +++ b/tests/client/config/test_yaml_functionality.py @@ -20,12 +20,12 @@ def test_yaml_extension_auto_detection(mcp_yaml_config_file: Path): config = MCPServersConfig.from_file(mcp_yaml_config_file) # Should successfully load the YAML file with all 9 servers - assert "stdio_server" in config.servers - assert "streamable_http_server_with_headers" in config.servers - assert "sse_server_with_explicit_type" in config.servers + assert config.has_server("stdio_server") + assert config.has_server("streamable_http_server_with_headers") + assert config.has_server("sse_server_with_explicit_type") # Verify a specific server - stdio_server = config.servers["stdio_server"] + stdio_server = config.server("stdio_server") assert isinstance(stdio_server, StdioServerConfig) assert stdio_server.command == "python" assert stdio_server.args == ["-m", "my_server"] @@ -40,13 +40,13 @@ def test_use_pyyaml_parameter_with_json_file(): config = MCPServersConfig.from_file(json_file, use_pyyaml=True) # Should work fine - PyYAML can parse JSON - assert len(config.servers) == 7 - assert "stdio_server" in config.servers + assert len(config.list_servers()) == 7 + assert config.has_server("stdio_server") # Verify it produces the same result as normal JSON parsing config_json = MCPServersConfig.from_file(json_file, use_pyyaml=False) - assert len(config.servers) == len(config_json.servers) - assert list(config.servers.keys()) == list(config_json.servers.keys()) + assert len(config.list_servers()) == len(config_json.list_servers()) + assert list(config.list_servers()) == list(config_json.list_servers()) def test_uvx_time_server(mcp_yaml_config_file: Path): @@ -54,10 +54,10 @@ def test_uvx_time_server(mcp_yaml_config_file: Path): config = MCPServersConfig.from_file(mcp_yaml_config_file) # Should have the time server - assert "time" in config.servers + assert config.has_server("time") # Verify the server configuration - time_server = config.servers["time"] + time_server = config.server("time") assert isinstance(time_server, StdioServerConfig) assert time_server.type == "stdio" # Should be auto-inferred from command field assert time_server.command == "uvx mcp-server-time" @@ -74,10 +74,10 @@ def test_streamable_http_server(mcp_yaml_config_file: Path): config = MCPServersConfig.from_file(mcp_yaml_config_file) # Should have the new streamable_http_server - assert "streamable_http_server" in config.servers + assert config.has_server("streamable_http_server") # Verify the server configuration - http_server = config.servers["streamable_http_server"] + http_server = config.server("streamable_http_server") assert isinstance(http_server, StreamableHTTPServerConfig) assert http_server.type == "streamable_http" # Should be auto-inferred assert http_server.url == "https://api.example.com/mcp" @@ -89,10 +89,10 @@ def test_npx_filesystem_server(mcp_yaml_config_file: Path): config = MCPServersConfig.from_file(mcp_yaml_config_file) # Should have the filesystem server - assert "filesystem" in config.servers + assert config.has_server("filesystem") # Verify the server configuration - filesystem_server = config.servers["filesystem"] + filesystem_server = config.server("filesystem") assert isinstance(filesystem_server, StdioServerConfig) assert filesystem_server.type == "stdio" # Should be auto-inferred from command field assert ( From 1ac383ebadebfd00be879b7d67b3f727d78cab9b Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Mon, 16 Jun 2025 23:27:47 -0700 Subject: [PATCH 42/42] Add tests/client/config/test_warning_functionality.py --- .../config/test_warning_functionality.py | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 tests/client/config/test_warning_functionality.py diff --git a/tests/client/config/test_warning_functionality.py b/tests/client/config/test_warning_functionality.py new file mode 100644 index 0000000000..de9eec67d2 --- /dev/null +++ b/tests/client/config/test_warning_functionality.py @@ -0,0 +1,219 @@ +"""Tests for warning functionality when accessing servers field directly.""" + +import warnings + +from mcp.client.config.mcp_servers_config import MCPServersConfig + + +def test_test_functions_no_warning(): + """Test that test functions (like this one) do not emit warnings.""" + config_data = {"servers": {"test-server": {"type": "stdio", "command": "python -m test_server"}}} + + config = MCPServersConfig.model_validate(config_data) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Access servers directly - should trigger warning + servers = config.servers + + assert len(w) == 1 + + # Verify we still get the servers + assert len(servers) == 1 + assert "test-server" in servers + + +def test_server_method_no_warning(): + """Test that using server() method does not emit warnings.""" + config_data = {"servers": {"test-server": {"type": "stdio", "command": "python -m test_server"}}} + + config = MCPServersConfig.model_validate(config_data) + + # Capture warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Use server() method - this should NOT trigger warning + server = config.server("test-server") + + # Check no warning was emitted + assert len(w) == 0 + + # Verify we get the server + assert server.type == "stdio" + assert server.command == "python -m test_server" + + +def test_list_servers_no_warning(): + """Test that using list_servers() method does not emit warnings.""" + config_data = {"servers": {"test-server": {"type": "stdio", "command": "python -m test_server"}}} + + config = MCPServersConfig.model_validate(config_data) + + # Capture warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Use list_servers() method - this should NOT trigger warning + server_names = config.list_servers() + + # Check no warning was emitted + assert len(w) == 0 + + # Verify we get the server names + assert server_names == ["test-server"] + + +def test_has_server_no_warning(): + """Test that using has_server() method does not emit warnings.""" + config_data = {"servers": {"test-server": {"type": "stdio", "command": "python -m test_server"}}} + + config = MCPServersConfig.model_validate(config_data) + + # Capture warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Use has_server() method - this should NOT trigger warning + exists = config.has_server("test-server") + + # Check no warning was emitted + assert len(w) == 0 + + # Verify result + assert exists is True + + +def test_other_field_access_no_warning(): + """Test that accessing other fields does not emit warnings.""" + config_data = { + "servers": {"test-server": {"type": "stdio", "command": "python -m test_server"}}, + "inputs": [{"id": "test-input", "description": "Test input"}], + } + + config = MCPServersConfig.model_validate(config_data) + + # Capture warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Access other fields - should NOT trigger warning + inputs = config.inputs + + # Check no warning was emitted + assert len(w) == 0 + + # Verify we get the inputs + assert inputs is not None + assert len(inputs) == 1 + assert inputs[0].id == "test-input" + + +def test_warning_logic_conditions(): + """Test that the warning logic correctly identifies different conditions.""" + config_data = {"servers": {"test-server": {"type": "stdio", "command": "python -m test_server"}}} + + config = MCPServersConfig.model_validate(config_data) + + # Test that accessing servers from this test function doesn't warn + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + servers = config.servers + assert len(servers) == 1 + assert len(w) == 1 + + +def test_internal_methods_use_servers_field(): + """Test that internal methods can access servers without warnings.""" + config_data = { + "servers": { + "test1": {"type": "stdio", "command": "python -m test1"}, + "test2": {"type": "stdio", "command": "python -m test2"}, + } + } + + config = MCPServersConfig.model_validate(config_data) + + # Test that internal methods work without warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # These methods internally access config.servers + server_list = config.list_servers() + has_test1 = config.has_server("test1") + server_obj = config.server("test1") + + # Should not generate warnings since these are internal method calls + assert len(w) == 0 + + # Verify results + assert "test1" in server_list + assert "test2" in server_list + assert has_test1 is True + assert server_obj.type == "stdio" + if server_obj.type == "stdio": + assert server_obj.command == "python -m test1" + + +def test_warning_system_attributes(): + """Test that the warning system correctly identifies caller attributes.""" + import inspect + + config_data = {"servers": {"test-server": {"type": "stdio", "command": "python -m test_server"}}} + + config = MCPServersConfig.model_validate(config_data) + + # Get current frame info to verify the test detection logic + current_frame = inspect.currentframe() + if current_frame: + filename = current_frame.f_code.co_filename + function_name = current_frame.f_code.co_name + + # Verify our test detection logic would work + assert "test_" in function_name # This function starts with test_ + assert "/tests/" in filename # This file is in tests directory + + # Access servers - should not warn due to test function detection + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + servers = config.servers + assert len(servers) == 1 + assert len(w) == 1 + + +def test_configuration_still_works(): + """Test that the warning system doesn't break normal configuration functionality.""" + config_data = { + "servers": { + "stdio-server": {"type": "stdio", "command": "python -m stdio_server", "args": ["--verbose"]}, + "http-server": {"type": "streamable_http", "url": "http://localhost:8000"}, + }, + "inputs": [{"id": "api-key", "description": "API key for authentication"}], + } + + config = MCPServersConfig.model_validate(config_data) + + # Test all functionality still works + assert config.list_servers() == ["stdio-server", "http-server"] + assert config.has_server("stdio-server") + assert not config.has_server("nonexistent") + + stdio_server = config.server("stdio-server") + assert stdio_server.type == "stdio" + assert stdio_server.command == "python -m stdio_server" + assert stdio_server.args == ["--verbose"] + + http_server = config.server("http-server") + assert http_server.type == "streamable_http" + assert http_server.url == "http://localhost:8000" + + # Test input validation + required_inputs = config.get_required_inputs() + assert required_inputs == ["api-key"] + + missing = config.validate_inputs({}) + assert missing == ["api-key"] + + no_missing = config.validate_inputs({"api-key": "secret"}) + assert no_missing == []