diff --git a/src/mcplint/discovery.py b/src/mcplint/discovery.py index cc6e784..a64f84b 100644 --- a/src/mcplint/discovery.py +++ b/src/mcplint/discovery.py @@ -17,6 +17,7 @@ ] HOME_CONFIGS: list[tuple[str, str]] = [ + ("~/.claude.json", "claude-code"), ("~/.cursor/mcp.json", "cursor"), ("~/.codeium/windsurf/mcp_config.json", "windsurf"), ("~/.codex/config.toml", "codex"), diff --git a/src/mcplint/parse.py b/src/mcplint/parse.py index 5b62723..4e49635 100644 --- a/src/mcplint/parse.py +++ b/src/mcplint/parse.py @@ -12,6 +12,8 @@ SERVER_KEYS = ("mcpServers", "servers", "mcp", "mcp_servers") +MAX_CONFIG_BYTES = 2_000_000 # 2 MB safety limit for parsing configs + def strip_jsonc(text: str) -> str: """Remove // and /* */ comments plus trailing commas from JSONC.""" @@ -157,6 +159,8 @@ def _load_data(path: Path, text: str) -> dict[str, Any] | None: def parse_config_file(path: Path, client: str | None = None) -> MCPConfigFile | None: try: + if path.stat().st_size > MAX_CONFIG_BYTES: + return None text = path.read_text(encoding="utf-8", errors="replace") except OSError: return None diff --git a/tests/test_parse.py b/tests/test_parse.py index dc10f17..b3856ef 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -86,6 +86,69 @@ def test_nested_configs_are_discovered(tmp_path: Path) -> None: assert all("node_modules" not in str(path) for path in configs) +def test_claude_json_user_scope_discovery(tmp_path: Path, monkeypatch) -> None: + from mcplint.discovery import discover + + home = tmp_path / "home" + home.mkdir() + claude_json = home / ".claude.json" + claude_json.write_text( + """ + { + "numCachedChunks": 12, + "mcpServers": { + "sqlite": { "command": "uvx", "args": ["mcp-server-sqlite"] } + } + } + """, + encoding="utf-8", + ) + + monkeypatch.setenv("HOME", str(home)) + configs, _ = discover([], include_home=True) + assert any(c.name == ".claude.json" for c in configs) + + parsed = parse_config_file(claude_json) + assert parsed is not None + assert parsed.client == "claude-code" + assert len(parsed.servers) == 1 + assert parsed.servers[0].name == "sqlite" + assert parsed.servers[0].command == ["uvx", "mcp-server-sqlite"] + + +def test_claude_json_malformed_skipped(tmp_path: Path, monkeypatch) -> None: + from mcplint.discovery import discover + + home = tmp_path / "home" + home.mkdir() + claude_json = home / ".claude.json" + claude_json.write_text("{invalid json", encoding="utf-8") + + monkeypatch.setenv("HOME", str(home)) + configs, _ = discover([], include_home=True) + assert any(c.name == ".claude.json" for c in configs) + + # Parsing should return None safely without crashing + assert parse_config_file(claude_json) is None + + +def test_claude_json_oversized_skipped(tmp_path: Path, monkeypatch) -> None: + from mcplint.discovery import discover + from mcplint.parse import MAX_CONFIG_BYTES + + home = tmp_path / "home" + home.mkdir() + claude_json = home / ".claude.json" + + # Write a file exceeding MAX_CONFIG_BYTES + claude_json.write_bytes(b" " * (MAX_CONFIG_BYTES + 1)) + + monkeypatch.setenv("HOME", str(home)) + configs, _ = discover([], include_home=True) + assert any(c.name == ".claude.json" for c in configs) + + # Parsing oversized config should safely return None without reading/crashing + assert parse_config_file(claude_json) is None def test_cline_linux_discovery_and_parse(tmp_path: Path, monkeypatch) -> None: from mcplint.discovery import discover @@ -172,4 +235,3 @@ def test_cline_macos_discovery_with_spaces(tmp_path: Path, monkeypatch) -> None: assert parsed is not None assert parsed.client == "cline" assert parsed.servers[0].name == "fetch" -