Skip to content

Commit 38eff49

Browse files
committed
Add JSONC support
1 parent 7d63c26 commit 38eff49

File tree

2 files changed

+224
-0
lines changed

2 files changed

+224
-0
lines changed

tests/client/config/mcp.jsonc

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
// MCP Servers Configuration
3+
// This file demonstrates various server configurations for testing
4+
"mcpServers": {
5+
6+
// Basic stdio server with explicit command and args
7+
// Type will be automatically inferred from the "command" field
8+
"stdio_server": {
9+
"command": "python",
10+
"args": ["-m", "my_server"],
11+
"env": {"DEBUG": "true"}
12+
},
13+
14+
// Stdio server with full command string that needs to be parsed
15+
// The effective_command will be "python" and effective_args will be ["-m", "my_server"]
16+
"stdio_server_with_full_command": {
17+
"command": "python -m my_server"
18+
},
19+
20+
// Stdio server with full command string AND explicit args
21+
// The effective_args will combine parsed command args with explicit args
22+
"stdio_server_with_full_command_and_explicit_args": {
23+
"command": "python -m my_server", // Will be parsed to: command="python", args=["-m", "my_server"]
24+
"args": ["--debug"] // Will be appended to parsed args: ["-m", "my_server", "--debug"]
25+
},
26+
27+
// Streamable HTTP server with headers
28+
// Type will be automatically inferred from the "url" field
29+
"streamable_http_server_with_headers": {
30+
"url": "https://api.example.com/mcp",
31+
"headers": {"Authorization": "Bearer token123"}
32+
},
33+
34+
// Servers with explicit types - these demonstrate that type inference
35+
// can be overridden by explicitly specifying the "type" field
36+
37+
// Stdio server with explicit type specification
38+
"stdio_server_with_explicit_type": {
39+
"type": "stdio", // Explicitly specified type
40+
"command": "python",
41+
"args": ["-m", "my_server"],
42+
"env": {"DEBUG": "true"}
43+
},
44+
45+
// Streamable HTTP server with explicit type specification
46+
"streamable_http_server_with_explicit_type": {
47+
"type": "streamable_http", // Explicitly specified type
48+
"url": "https://api.example.com/mcp"
49+
},
50+
51+
// SSE (Server-Sent Events) server with explicit type specification
52+
"sse_server_with_explicit_type": {
53+
"type": "sse", // Explicitly specified type
54+
"url": "https://api.example.com/sse"
55+
}
56+
}
57+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# stdlib imports
2+
import json
3+
import re
4+
from pathlib import Path
5+
6+
# third party imports
7+
import pytest
8+
9+
# local imports
10+
from mcp.client.config.mcp_servers_config import (
11+
MCPServersConfig,
12+
SSEServerConfig,
13+
StdioServerConfig,
14+
StreamableHTTPServerConfig,
15+
)
16+
17+
18+
def strip_jsonc_comments(jsonc_content: str) -> str:
19+
"""
20+
Simple function to strip comments from JSONC content.
21+
This handles basic line comments (//) and block comments (/* */).
22+
"""
23+
# Remove single-line comments (// comment)
24+
lines = jsonc_content.split("\n")
25+
processed_lines = []
26+
27+
for line in lines:
28+
# Find the position of // that's not inside a string
29+
in_string = False
30+
escaped = False
31+
comment_pos = -1
32+
33+
for i, char in enumerate(line):
34+
if escaped:
35+
escaped = False
36+
continue
37+
38+
if char == "\\":
39+
escaped = True
40+
continue
41+
42+
if char == '"' and not escaped:
43+
in_string = not in_string
44+
continue
45+
46+
if not in_string and char == "/" and i + 1 < len(line) and line[i + 1] == "/":
47+
comment_pos = i
48+
break
49+
50+
if comment_pos >= 0:
51+
line = line[:comment_pos].rstrip()
52+
53+
processed_lines.append(line)
54+
55+
content = "\n".join(processed_lines)
56+
57+
# Remove block comments (/* comment */)
58+
# This is a simplified approach - not perfect for all edge cases
59+
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
60+
61+
return content
62+
63+
64+
@pytest.fixture
65+
def mcp_jsonc_config_file() -> Path:
66+
"""Return path to the mcp.jsonc config file with comments"""
67+
return Path(__file__).parent / "mcp.jsonc"
68+
69+
70+
def test_jsonc_file_exists(mcp_jsonc_config_file: Path):
71+
"""Test that the JSONC configuration file exists."""
72+
assert mcp_jsonc_config_file.exists(), f"JSONC config file not found: {mcp_jsonc_config_file}"
73+
74+
75+
def test_jsonc_content_can_be_parsed():
76+
"""Test that JSONC content can be parsed after stripping comments."""
77+
jsonc_file = Path(__file__).parent / "mcp.jsonc"
78+
79+
with open(jsonc_file) as f:
80+
jsonc_content = f.read()
81+
82+
# Strip comments and parse as JSON
83+
json_content = strip_jsonc_comments(jsonc_content)
84+
parsed_data = json.loads(json_content)
85+
86+
# Validate the structure
87+
assert "mcpServers" in parsed_data
88+
assert isinstance(parsed_data["mcpServers"], dict)
89+
90+
# Check that some expected servers are present
91+
servers = parsed_data["mcpServers"]
92+
assert "stdio_server" in servers
93+
assert "streamable_http_server_with_headers" in servers
94+
assert "sse_server_with_explicit_type" in servers
95+
96+
97+
def test_jsonc_config_can_be_loaded_as_mcp_config():
98+
"""Test that JSONC content can be loaded into MCPServersConfig after processing."""
99+
jsonc_file = Path(__file__).parent / "mcp.jsonc"
100+
101+
with open(jsonc_file) as f:
102+
jsonc_content = f.read()
103+
104+
# Strip comments and create config
105+
json_content = strip_jsonc_comments(jsonc_content)
106+
parsed_data = json.loads(json_content)
107+
config = MCPServersConfig.model_validate(parsed_data)
108+
109+
# Test that all expected servers are loaded correctly
110+
assert len(config.servers) == 7 # Should have 7 servers total
111+
112+
# Test stdio server
113+
stdio_server = config.servers["stdio_server"]
114+
assert isinstance(stdio_server, StdioServerConfig)
115+
assert stdio_server.command == "python"
116+
assert stdio_server.type == "stdio"
117+
118+
# Test streamable HTTP server
119+
http_server = config.servers["streamable_http_server_with_headers"]
120+
assert isinstance(http_server, StreamableHTTPServerConfig)
121+
assert http_server.url == "https://api.example.com/mcp"
122+
assert http_server.type == "streamable_http"
123+
124+
# Test SSE server
125+
sse_server = config.servers["sse_server_with_explicit_type"]
126+
assert isinstance(sse_server, SSEServerConfig)
127+
assert sse_server.url == "https://api.example.com/sse"
128+
assert sse_server.type == "sse"
129+
130+
131+
def test_jsonc_comments_are_properly_stripped():
132+
"""Test that various comment types are properly stripped from JSONC."""
133+
test_jsonc = """
134+
{
135+
// This is a line comment
136+
"key1": "value1",
137+
"key2": "value with // not a comment inside string",
138+
/* This is a
139+
block comment */
140+
"key3": "value3" // Another line comment
141+
}
142+
"""
143+
144+
result = strip_jsonc_comments(test_jsonc)
145+
parsed = json.loads(result)
146+
147+
assert parsed["key1"] == "value1"
148+
assert parsed["key2"] == "value with // not a comment inside string"
149+
assert parsed["key3"] == "value3"
150+
151+
152+
def test_jsonc_and_json_configs_are_equivalent():
153+
"""Test that the JSONC and JSON configs contain the same data after comment removal."""
154+
json_file = Path(__file__).parent / "mcp.json"
155+
jsonc_file = Path(__file__).parent / "mcp.jsonc"
156+
157+
# Load JSON config
158+
with open(json_file) as f:
159+
json_data = json.load(f)
160+
161+
# Load JSONC config and strip comments
162+
with open(jsonc_file) as f:
163+
jsonc_content = f.read()
164+
jsonc_data = json.loads(strip_jsonc_comments(jsonc_content))
165+
166+
# They should be equivalent
167+
assert json_data == jsonc_data

0 commit comments

Comments
 (0)