diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 5f115aef89..852ffc3850 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -30,9 +30,10 @@ jobs: env: SKIP: no-commit-to-branch,readme-v1-frozen - # TODO(Max): Drop this in v2. + # TODO(Max): Drop this in v2. Deliberate updates (e.g. the v2 status + # banner) go through the 'override-readme-freeze' label. - name: Check README.md is not modified - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'override-readme-freeze') run: | git fetch --no-tags --depth=1 origin "$BASE_SHA" if git diff --name-only "$BASE_SHA" -- README.md | grep -q .; then diff --git a/README.md b/README.md index 487d48bee4..884a9d5a58 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ > [!NOTE] > **This README documents v1.x of the MCP Python SDK (the current stable release).** > -> For v1.x code and documentation, see the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x). -> For the upcoming v2 documentation (pre-alpha, in development on `main`), see [`README.v2.md`](README.v2.md). +> **v2 is in alpha.** Pre-releases are published to PyPI as `2.0.0aN` and can be installed with an explicit pin, for example `pip install mcp==2.0.0a1`. See [`README.v2.md`](README.v2.md) for the v2 documentation and the [migration guide](docs/migration.md) for what's changed. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27. If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands. +> +> For v1.x code and documentation, see the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x). v1.x is in maintenance mode and continues to receive critical bug fixes and security patches. ## Table of Contents diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py index 93bf218fbb..e65379682a 100644 --- a/src/mcp/cli/claude.py +++ b/src/mcp/cli/claude.py @@ -1,5 +1,6 @@ """Claude app integration utilities.""" +import importlib.metadata import json import os import shutil @@ -11,7 +12,24 @@ logger = get_logger(__name__) -MCP_PACKAGE = "mcp[cli]" + +def mcp_requirement(package: str = "mcp") -> str: + """Requirement string pinning spawned environments to the running SDK version. + + `uv run --with mcp` resolves the requirement in a fresh environment, where + an unpinned `mcp` means the latest stable release — not necessarily the + version the user installed (pre-releases in particular are never selected + without an explicit pin). Source builds carry dev/local version segments + that are not published to PyPI, so they fall back to the unpinned form, + as does a missing distribution (no metadata to pin from). + """ + try: + version = importlib.metadata.version("mcp") + except importlib.metadata.PackageNotFoundError: + return package + if ".dev" in version or "+" in version: + return package + return f"{package}=={version}" def get_claude_config_path() -> Path | None: # pragma: no cover @@ -102,7 +120,7 @@ def update_claude_config( args = ["run", "--frozen"] # Collect all packages in a set to deduplicate - packages = {MCP_PACKAGE} + packages = {mcp_requirement("mcp[cli]")} if with_packages: packages.update(pkg for pkg in with_packages if pkg) diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index 44e2bae48e..eb06bf087a 100644 --- a/src/mcp/cli/cli.py +++ b/src/mcp/cli/cli.py @@ -70,7 +70,7 @@ def _build_uv_command( """Build the uv run command that runs an MCP server through mcp run.""" cmd = ["uv"] - cmd.extend(["run", "--with", "mcp"]) + cmd.extend(["run", "--with", claude.mcp_requirement()]) if with_editable: cmd.extend(["--with-editable", str(with_editable)]) diff --git a/tests/cli/test_claude.py b/tests/cli/test_claude.py index 73d4f0eb52..d0a74e0d00 100644 --- a/tests/cli/test_claude.py +++ b/tests/cli/test_claude.py @@ -1,24 +1,68 @@ """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, update_claude_config +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 get_claude_config_path and get_uv_path mocked.""" + """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()) return config["mcpServers"][name] @@ -31,7 +75,7 @@ def test_generates_uv_run_command(config_dir: Path): resolved = Path("server.py").resolve() assert _read_server(config_dir, "my_server") == { "command": "/fake/bin/uv", - "args": ["run", "--frozen", "--with", "mcp[cli]", "mcp", "run", f"{resolved}:app"], + "args": ["run", "--frozen", "--with", "mcp[cli]==1.2.3", "mcp", "run", f"{resolved}:app"], } @@ -43,11 +87,19 @@ def test_file_spec_without_object_suffix(config_dir: Path): def test_with_packages_sorted_and_deduplicated(config_dir: Path): - """Extra packages should appear as --with flags, sorted and deduplicated with mcp[cli].""" + """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]", "--with", "zebra"] + 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): diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index 44f4ab4d31..d217d82fc7 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -1,3 +1,4 @@ +import importlib.metadata import subprocess import sys from pathlib import Path @@ -8,6 +9,15 @@ from mcp.cli.cli import _build_uv_command, _get_npx_command, _parse_file_path # type: ignore[reportPrivateUsage] +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.mark.parametrize( "spec, expected_obj", [ @@ -38,14 +48,23 @@ def test_parse_file_exit_on_dir(tmp_path: Path): _parse_file_path(str(dir_path)) -def test_build_uv_command_minimal(): - """Should emit core command when no extras specified.""" +def test_build_uv_command_pins_the_running_mcp_version(monkeypatch: pytest.MonkeyPatch): + """The spawned environment installs the same SDK version that is running, not the latest stable.""" + _set_mcp_version(monkeypatch, "1.2.3") + cmd = _build_uv_command("foo.py") + assert cmd == ["uv", "run", "--with", "mcp==1.2.3", "mcp", "run", "foo.py"] + + +def test_build_uv_command_leaves_source_builds_unpinned(monkeypatch: pytest.MonkeyPatch): + """Source-build versions are not on PyPI, so the requirement stays unpinned.""" + _set_mcp_version(monkeypatch, "2.0.0a2.dev3+g0123abc") cmd = _build_uv_command("foo.py") assert cmd == ["uv", "run", "--with", "mcp", "mcp", "run", "foo.py"] -def test_build_uv_command_adds_editable_and_packages(): +def test_build_uv_command_adds_editable_and_packages(monkeypatch: pytest.MonkeyPatch): """Should include --with-editable and every --with pkg in correct order.""" + _set_mcp_version(monkeypatch, "1.2.3") test_path = Path("/pkg") cmd = _build_uv_command( "foo.py", @@ -56,7 +75,7 @@ def test_build_uv_command_adds_editable_and_packages(): "uv", "run", "--with", - "mcp", + "mcp==1.2.3", "--with-editable", str(test_path), # Use str() to match what the function does "--with",