-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Expand file tree
/
Copy pathtest_claude.py
More file actions
213 lines (144 loc) · 9.04 KB
/
Copy pathtest_claude.py
File metadata and controls
213 lines (144 loc) · 9.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""Tests for mcp.cli.claude — Claude Desktop config file generation."""
import importlib.metadata
import json
from pathlib import Path
from typing import Any
import pytest
from mcp.cli.claude import get_uv_path, mcp_requirement, update_claude_config
def _set_mcp_version(monkeypatch: pytest.MonkeyPatch, version: str) -> None:
real_version = importlib.metadata.version
def fake_version(distribution_name: str) -> str:
return version if distribution_name == "mcp" else real_version(distribution_name)
monkeypatch.setattr(importlib.metadata, "version", fake_version)
@pytest.fixture
def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Temp Claude config dir with the config path, uv path, and SDK version mocked."""
claude_dir = tmp_path / "Claude"
claude_dir.mkdir()
monkeypatch.setattr("mcp.cli.claude.get_claude_config_path", lambda: claude_dir)
monkeypatch.setattr("mcp.cli.claude.get_uv_path", lambda: "/fake/bin/uv")
# The ambient version is a dev build in the repo venv but varies by
# environment; pin it so the generated --with requirement is stable.
_set_mcp_version(monkeypatch, "1.2.3")
return claude_dir
def test_mcp_requirement_pins_release_versions(monkeypatch: pytest.MonkeyPatch):
"""Release versions produce an exact pin so spawned environments run the installed SDK version."""
_set_mcp_version(monkeypatch, "2.0.0a1")
assert mcp_requirement() == "mcp==2.0.0a1"
assert mcp_requirement("mcp[cli]") == "mcp[cli]==2.0.0a1"
def test_mcp_requirement_leaves_dev_versions_unpinned(monkeypatch: pytest.MonkeyPatch):
"""Dev versions are not published to PyPI, so the requirement falls back to the unpinned package."""
_set_mcp_version(monkeypatch, "2.0.0a2.dev3")
assert mcp_requirement() == "mcp"
assert mcp_requirement("mcp[cli]") == "mcp[cli]"
def test_mcp_requirement_leaves_local_versions_unpinned(monkeypatch: pytest.MonkeyPatch):
"""Local version segments (source builds) are not published to PyPI, so no pin is emitted."""
_set_mcp_version(monkeypatch, "1.2.3+g0123abc")
assert mcp_requirement() == "mcp"
def test_mcp_requirement_falls_back_when_mcp_is_not_installed(monkeypatch: pytest.MonkeyPatch):
"""Without distribution metadata there is no version to pin, so the requirement stays unpinned."""
def raise_not_found(distribution_name: str) -> str:
raise importlib.metadata.PackageNotFoundError(distribution_name)
monkeypatch.setattr(importlib.metadata, "version", raise_not_found)
assert mcp_requirement() == "mcp"
assert mcp_requirement("mcp[cli]") == "mcp[cli]"
def _read_server(config_dir: Path, name: str) -> dict[str, Any]:
config = json.loads((config_dir / "claude_desktop_config.json").read_text(encoding="utf-8"))
return config["mcpServers"][name]
def test_generates_uv_run_command(config_dir: Path):
"""Should write a uv run command that invokes mcp run on the resolved file spec."""
assert update_claude_config(file_spec="server.py:app", server_name="my_server")
resolved = Path("server.py").resolve()
assert _read_server(config_dir, "my_server") == {
"command": "/fake/bin/uv",
"args": ["run", "--frozen", "--with", "mcp[cli]==1.2.3", "mcp", "run", f"{resolved}:app"],
}
def test_file_spec_without_object_suffix(config_dir: Path):
"""File specs without :object should still resolve to an absolute path."""
assert update_claude_config(file_spec="server.py", server_name="s")
assert _read_server(config_dir, "s")["args"][-1] == str(Path("server.py").resolve())
def test_with_packages_sorted_and_deduplicated(config_dir: Path):
"""Extra packages should appear as sorted --with flags with duplicates removed."""
assert update_claude_config(file_spec="s.py:app", server_name="s", with_packages=["zebra", "aardvark", "zebra"])
args = _read_server(config_dir, "s")["args"]
assert args[:8] == ["run", "--frozen", "--with", "aardvark", "--with", "mcp[cli]==1.2.3", "--with", "zebra"]
def test_explicit_mcp_cli_kept_alongside_pinned_requirement(config_dir: Path):
"""A user-supplied mcp[cli] no longer collapses into the pinned requirement; uv resolves both to the pin."""
assert update_claude_config(file_spec="s.py:app", server_name="s", with_packages=["mcp[cli]"])
args = _read_server(config_dir, "s")["args"]
assert args[:6] == ["run", "--frozen", "--with", "mcp[cli]", "--with", "mcp[cli]==1.2.3"]
def test_with_editable_adds_flag(config_dir: Path, tmp_path: Path):
"""with_editable should add --with-editable after the --with flags."""
editable = tmp_path / "project"
assert update_claude_config(file_spec="s.py:app", server_name="s", with_editable=editable)
args = _read_server(config_dir, "s")["args"]
assert args[4:6] == ["--with-editable", str(editable)]
def test_env_vars_written(config_dir: Path):
"""env_vars should be written under the server's env key."""
assert update_claude_config(file_spec="s.py:app", server_name="s", env_vars={"KEY": "val"})
assert _read_server(config_dir, "s")["env"] == {"KEY": "val"}
def test_existing_env_vars_merged_new_wins(config_dir: Path):
"""Re-installing should merge env vars, with new values overriding existing ones."""
(config_dir / "claude_desktop_config.json").write_text(
json.dumps({"mcpServers": {"s": {"env": {"OLD": "keep", "KEY": "old"}}}}), encoding="utf-8"
)
assert update_claude_config(file_spec="s.py:app", server_name="s", env_vars={"KEY": "new"})
assert _read_server(config_dir, "s")["env"] == {"OLD": "keep", "KEY": "new"}
def test_existing_env_vars_preserved_without_new(config_dir: Path):
"""Re-installing without env_vars should keep the existing env block intact."""
(config_dir / "claude_desktop_config.json").write_text(
json.dumps({"mcpServers": {"s": {"env": {"KEEP": "me"}}}}), encoding="utf-8"
)
assert update_claude_config(file_spec="s.py:app", server_name="s")
assert _read_server(config_dir, "s")["env"] == {"KEEP": "me"}
def test_other_servers_preserved(config_dir: Path):
"""Installing a new server must not clobber existing entries, non-ASCII text included (the file is UTF-8)."""
other = {"command": "C:\\Users\\张伟\\uv.exe", "env": {"CITY": "Zürich"}}
config_file = config_dir / "claude_desktop_config.json"
config_file.write_text(json.dumps({"mcpServers": {"文件": other}}, ensure_ascii=False), encoding="utf-8")
assert update_claude_config(file_spec="s.py:app", server_name="s")
config = json.loads(config_file.read_text(encoding="utf-8"))
assert set(config["mcpServers"]) == {"文件", "s"}
assert config["mcpServers"]["文件"] == other
@pytest.mark.parametrize("codec", ["utf-8-sig", "utf-16"])
def test_existing_config_with_a_bom_is_accepted(config_dir: Path, codec: str):
"""A config saved by Windows tooling (UTF-8 with BOM, or PowerShell 5's UTF-16 `>`) can still be installed into."""
config_file = config_dir / "claude_desktop_config.json"
config_file.write_bytes(json.dumps({"mcpServers": {"other": {"command": "x"}}}).encode(codec))
assert update_claude_config(file_spec="s.py:app", server_name="s")
assert set(json.loads(config_file.read_bytes())["mcpServers"]) == {"other", "s"}
def test_raises_when_config_dir_missing(monkeypatch: pytest.MonkeyPatch):
"""Should raise RuntimeError when Claude Desktop config dir can't be found."""
monkeypatch.setattr("mcp.cli.claude.get_claude_config_path", lambda: None)
monkeypatch.setattr("mcp.cli.claude.get_uv_path", lambda: "/fake/bin/uv")
with pytest.raises(RuntimeError, match="Claude Desktop config directory not found"):
update_claude_config(file_spec="s.py:app", server_name="s")
@pytest.mark.parametrize("which_result, expected", [("/usr/local/bin/uv", "/usr/local/bin/uv"), (None, "uv")])
def test_get_uv_path(monkeypatch: pytest.MonkeyPatch, which_result: str | None, expected: str):
"""Should return shutil.which's result, or fall back to bare 'uv' when not on PATH."""
def fake_which(cmd: str) -> str | None:
return which_result
monkeypatch.setattr("shutil.which", fake_which)
assert get_uv_path() == expected
@pytest.mark.parametrize(
"file_spec, expected_last_arg",
[
("C:\\Users\\server.py", "C:\\Users\\server.py"),
("C:\\Users\\server.py:app", "C:\\Users\\server.py:app"),
],
)
def test_windows_drive_letter_not_split(
config_dir: Path, monkeypatch: pytest.MonkeyPatch, file_spec: str, expected_last_arg: str
):
"""Drive-letter paths like 'C:\\server.py' must not be split on the drive colon.
Before the fix, a bare 'C:\\path\\server.py' would hit rsplit(":", 1) and yield
("C", "\\path\\server.py"), calling resolve() on Path("C") instead of the full path.
"""
seen: list[str] = []
def fake_resolve(self: Path) -> Path:
seen.append(str(self))
return self
monkeypatch.setattr(Path, "resolve", fake_resolve)
assert update_claude_config(file_spec=file_spec, server_name="s")
assert seen == ["C:\\Users\\server.py"]
assert _read_server(config_dir, "s")["args"][-1] == expected_last_arg