Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- omit in toc -->
## Table of Contents
Expand Down
22 changes: 20 additions & 2 deletions src/mcp/cli/claude.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Claude app integration utilities."""

import importlib.metadata
import json
import os
import shutil
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down
62 changes: 57 additions & 5 deletions tests/cli/test_claude.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting we have a Claude test but guess it's pre-existing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeaaa it is definitely weird, will have to think whether we add all CLIs later or something



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]
Expand All @@ -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"],
}


Expand All @@ -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):
Expand Down
27 changes: 23 additions & 4 deletions tests/cli/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib.metadata
import subprocess
import sys
from pathlib import Path
Expand All @@ -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",
[
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading