Skip to content

Commit 85036e0

Browse files
committed
Strip out // comments (JSONC support)
1 parent 8c8e657 commit 85036e0

File tree

2 files changed

+201
-3
lines changed

2 files changed

+201
-3
lines changed

src/mcp/client/config/mcp_servers_config.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,44 @@ def replace_input(match: re.Match[str]) -> str:
189189
else:
190190
return data
191191

192+
@classmethod
193+
def _strip_json_comments(cls, content: str) -> str:
194+
"""Strip // comments from JSON content, being careful not to remove // inside strings."""
195+
result = []
196+
lines = content.split("\n")
197+
198+
for line in lines:
199+
# Track if we're inside a string
200+
in_string = False
201+
escaped = False
202+
comment_start = -1
203+
204+
for i, char in enumerate(line):
205+
if escaped:
206+
escaped = False
207+
continue
208+
209+
if char == "\\" and in_string:
210+
escaped = True
211+
continue
212+
213+
if char == '"':
214+
in_string = not in_string
215+
continue
216+
217+
# Look for // comment start when not in string
218+
if not in_string and char == "/" and i + 1 < len(line) and line[i + 1] == "/":
219+
comment_start = i
220+
break
221+
222+
# If we found a comment, remove it
223+
if comment_start != -1:
224+
line = line[:comment_start].rstrip()
225+
226+
result.append(line)
227+
228+
return "\n".join(result)
229+
192230
@classmethod
193231
def from_file(
194232
cls, config_path: Path | str, use_pyyaml: bool = False, inputs: dict[str, str] | None = None
@@ -207,15 +245,19 @@ def from_file(
207245
config_path = config_path.expanduser() # Expand ~ to home directory
208246

209247
with open(config_path) as config_file:
248+
content = config_file.read()
249+
210250
# Check if YAML parsing is requested
211251
should_use_yaml = use_pyyaml or config_path.suffix.lower() in (".yaml", ".yml")
212252

213253
if should_use_yaml:
214254
if not yaml:
215255
raise ImportError("PyYAML is required to parse YAML files. ")
216-
data = yaml.safe_load(config_file)
256+
data = yaml.safe_load(content)
217257
else:
218-
data = json.load(config_file)
258+
# Strip comments from JSON content (JSONC support)
259+
cleaned_content = cls._strip_json_comments(content)
260+
data = json.loads(cleaned_content)
219261

220262
# Create a preliminary config to validate inputs if they're defined
221263
preliminary_config = cls.model_validate(data)

tests/client/config/test_mcp_servers_config.py

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# stdlib imports
2-
from pathlib import Path
32
import json
3+
from pathlib import Path
44

55
# third party imports
66
import pytest
@@ -587,3 +587,159 @@ def test_input_definition_with_yaml_file(tmp_path: Path):
587587
assert isinstance(server, StdioServerConfig)
588588
assert server.command == "python -m test_module"
589589
assert server.args == ["--config", "/etc/config.json"]
590+
591+
592+
def test_jsonc_comment_stripping():
593+
"""Test stripping of // comments from JSONC content."""
594+
# Test basic comment stripping
595+
content_with_comments = """
596+
{
597+
// This is a comment
598+
"servers": {
599+
"test_server": {
600+
"type": "stdio",
601+
"command": "python test.py" // End of line comment
602+
}
603+
},
604+
// Another comment
605+
"inputs": [] // Final comment
606+
}
607+
"""
608+
609+
stripped = MCPServersConfig._strip_json_comments(content_with_comments)
610+
config = MCPServersConfig.model_validate(json.loads(stripped))
611+
612+
assert "test_server" in config.servers
613+
server = config.servers["test_server"]
614+
assert isinstance(server, StdioServerConfig)
615+
assert server.command == "python test.py"
616+
617+
618+
def test_jsonc_comments_inside_strings_preserved():
619+
"""Test that // inside strings are not treated as comments."""
620+
content_with_urls = """
621+
{
622+
"servers": {
623+
"web_server": {
624+
"type": "sse",
625+
"url": "https://example.com/api/endpoint" // This is a comment
626+
},
627+
"protocol_server": {
628+
"type": "stdio",
629+
"command": "node server.js --url=http://localhost:3000"
630+
}
631+
}
632+
}
633+
"""
634+
635+
stripped = MCPServersConfig._strip_json_comments(content_with_urls)
636+
config = MCPServersConfig.model_validate(json.loads(stripped))
637+
638+
web_server = config.servers["web_server"]
639+
assert isinstance(web_server, SSEServerConfig)
640+
assert web_server.url == "https://example.com/api/endpoint"
641+
642+
protocol_server = config.servers["protocol_server"]
643+
assert isinstance(protocol_server, StdioServerConfig)
644+
# The // in the URL should be preserved
645+
assert "http://localhost:3000" in protocol_server.command
646+
647+
648+
def test_jsonc_escaped_quotes_handling():
649+
"""Test that escaped quotes in strings are handled correctly."""
650+
content_with_escaped = """
651+
{
652+
"servers": {
653+
"test_server": {
654+
"type": "stdio",
655+
"command": "python -c \\"print('Hello // World')\\"", // Comment after escaped quotes
656+
"description": "Server with \\"escaped quotes\\" and // in string"
657+
}
658+
}
659+
}
660+
"""
661+
662+
stripped = MCPServersConfig._strip_json_comments(content_with_escaped)
663+
config = MCPServersConfig.model_validate(json.loads(stripped))
664+
665+
server = config.servers["test_server"]
666+
assert isinstance(server, StdioServerConfig)
667+
# The command should preserve the escaped quotes and // inside the string
668+
assert server.command == "python -c \"print('Hello // World')\""
669+
670+
671+
def test_from_file_with_jsonc_comments(tmp_path: Path):
672+
"""Test loading JSONC file with comments via from_file method."""
673+
jsonc_content = """
674+
{
675+
// Configuration for MCP servers
676+
"inputs": [
677+
{
678+
"type": "promptString",
679+
"id": "api-key", // Secret API key
680+
"description": "API Key for authentication"
681+
}
682+
],
683+
"servers": {
684+
// Main server configuration
685+
"main_server": {
686+
"type": "sse",
687+
"url": "https://api.example.com/mcp/sse", // Production URL
688+
"headers": {
689+
"Authorization": "Bearer ${input:api-key}" // Dynamic token
690+
}
691+
}
692+
}
693+
// End of configuration
694+
}
695+
"""
696+
697+
config_file = tmp_path / "test_config.json"
698+
config_file.write_text(jsonc_content)
699+
700+
inputs = {"api-key": "secret123"}
701+
702+
# Should load successfully despite comments
703+
config = MCPServersConfig.from_file(config_file, inputs=inputs)
704+
705+
# Verify input definitions were parsed
706+
assert config.inputs is not None
707+
assert len(config.inputs) == 1
708+
assert config.inputs[0].id == "api-key"
709+
710+
# Verify server configuration and input substitution
711+
server = config.servers["main_server"]
712+
assert isinstance(server, SSEServerConfig)
713+
assert server.url == "https://api.example.com/mcp/sse"
714+
assert server.headers == {"Authorization": "Bearer secret123"}
715+
716+
717+
def test_jsonc_multiline_strings_with_comments():
718+
"""Test that comments in multiline scenarios are handled correctly."""
719+
content = """
720+
{
721+
"servers": {
722+
"test1": {
723+
// Comment before
724+
"type": "stdio", // Comment after
725+
"command": "python server.py"
726+
}, // Comment after object
727+
"test2": { "type": "sse", "url": "https://example.com" } // Inline comment
728+
}
729+
}
730+
"""
731+
732+
stripped = MCPServersConfig._strip_json_comments(content)
733+
config = MCPServersConfig.model_validate(json.loads(stripped))
734+
735+
assert len(config.servers) == 2
736+
assert "test1" in config.servers
737+
assert "test2" in config.servers
738+
739+
test1 = config.servers["test1"]
740+
assert isinstance(test1, StdioServerConfig)
741+
assert test1.command == "python server.py"
742+
743+
test2 = config.servers["test2"]
744+
assert isinstance(test2, SSEServerConfig)
745+
assert test2.url == "https://example.com"

0 commit comments

Comments
 (0)