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
20 changes: 12 additions & 8 deletions src/mcp/cli/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def get_claude_config_path() -> Path | None: # pragma: no cover
def get_uv_path() -> str:
"""Get the full path to the uv executable."""
uv_path = shutil.which("uv")
if not uv_path: # pragma: no cover
if not uv_path:
logger.error(
"uv executable not found in PATH, falling back to 'uv'. Please ensure uv is installed and in your PATH"
)
Expand Down Expand Up @@ -65,7 +65,7 @@ def update_claude_config(
"""
config_dir = get_claude_config_path()
uv_path = get_uv_path()
if not config_dir: # pragma: no cover
if not config_dir:
raise RuntimeError(
"Claude Desktop config directory not found. Please ensure Claude Desktop"
" is installed and has been run at least once to initialize its config."
Expand All @@ -90,7 +90,7 @@ def update_claude_config(
config["mcpServers"] = {}

# Always preserve existing env vars and merge with new ones
if server_name in config["mcpServers"] and "env" in config["mcpServers"][server_name]: # pragma: no cover
if server_name in config["mcpServers"] and "env" in config["mcpServers"][server_name]:
existing_env = config["mcpServers"][server_name]["env"]
if env_vars:
# New vars take precedence over existing ones
Expand All @@ -103,22 +103,26 @@ def update_claude_config(

# Collect all packages in a set to deduplicate
packages = {MCP_PACKAGE}
if with_packages: # pragma: no cover
if with_packages:
packages.update(pkg for pkg in with_packages if pkg)

# Add all packages with --with
for pkg in sorted(packages):
args.extend(["--with", pkg])

if with_editable: # pragma: no cover
if with_editable:
args.extend(["--with-editable", str(with_editable)])

# Convert file path to absolute before adding to command
# Split off any :object suffix first
if ":" in file_spec:
# First check if we have a Windows path (e.g., C:\...)
has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":"

# Split on the last colon, but only if it's not part of the Windows drive letter
if ":" in (file_spec[2:] if has_windows_drive else file_spec):
file_path, server_object = file_spec.rsplit(":", 1)
file_spec = f"{Path(file_path).resolve()}:{server_object}"
else: # pragma: no cover
else:
file_spec = str(Path(file_spec).resolve())

# Add mcp run command
Expand All @@ -127,7 +131,7 @@ def update_claude_config(
server_config: dict[str, Any] = {"command": uv_path, "args": args}

# Add environment variables if specified
if env_vars: # pragma: no cover
if env_vars:
server_config["env"] = env_vars

config["mcpServers"][server_name] = server_config
Expand Down
51 changes: 37 additions & 14 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,16 +388,23 @@ async def run(
await stack.enter_async_context(task_support.run())

async with anyio.create_task_group() as tg:
async for message in session.incoming_messages:
logger.debug("Received message: %s", message)

tg.start_soon(
self._handle_message,
message,
session,
lifespan_context,
raise_exceptions,
)
try:
async for message in session.incoming_messages:
logger.debug("Received message: %s", message)

tg.start_soon(
self._handle_message,
message,
session,
lifespan_context,
raise_exceptions,
)
finally:
# Transport closed: cancel in-flight handlers. Without this the
# TG join waits for them, and when they eventually try to
# respond they hit a closed write stream (the session's
# _receive_loop closed it when the read stream ended).
tg.cancel_scope.cancel()

async def _handle_message(
self,
Expand Down Expand Up @@ -475,16 +482,32 @@ async def _handle_request(
except MCPError as err:
response = err.error
except anyio.get_cancelled_exc_class():
logger.info("Request %s cancelled - duplicate response suppressed", message.request_id)
return
if message.cancelled:
# Client sent CancelledNotification; responder.cancel() already
# sent an error response, so skip the duplicate.
logger.info("Request %s cancelled - duplicate response suppressed", message.request_id)
return
# Transport-close cancellation from the TG in run(); re-raise so the
# TG swallows its own cancellation.
raise
except Exception as err:
if raise_exceptions: # pragma: no cover
raise err
response = types.ErrorData(code=0, message=str(err))
else: # pragma: no cover
response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found")

try:
await message.respond(response)
else: # pragma: no cover
await message.respond(types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found"))
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
# Transport closed between handler unblocking and respond. Happens
# when _receive_loop's finally wakes a handler blocked on
# send_request: the handler runs to respond() before run()'s TG
# cancel fires, but after the write stream closed. Closed if our
# end closed (_receive_loop's async-with exit); Broken if the peer
# end closed first (streamable_http terminate()).
logger.debug("Response for %s dropped - transport closed", message.request_id)
return

logger.debug("Response sent")

Expand Down
6 changes: 4 additions & 2 deletions src/mcp/shared/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def __exit__(
) -> None:
"""Exit the context manager, performing cleanup and notifying completion."""
try:
if self._completed: # pragma: no branch
if self._completed:
self._on_complete(self)
finally:
self._entered = False
Expand Down Expand Up @@ -418,7 +418,9 @@ async def _receive_loop(self) -> None:
finally:
# after the read stream is closed, we need to send errors
# to any pending requests
for id, stream in self._response_streams.items():
# Snapshot: stream.send() wakes the waiter, whose finally pops
# from _response_streams before the next __next__() call.
for id, stream in list(self._response_streams.items()):
error = ErrorData(code=CONNECTION_CLOSED, message="Connection closed")
try:
await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error))
Expand Down
146 changes: 146 additions & 0 deletions tests/cli/test_claude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Tests for mcp.cli.claude — Claude Desktop config file generation."""

import json
from pathlib import Path
from typing import Any

import pytest

from mcp.cli.claude import get_uv_path, update_claude_config


@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."""
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")
return claude_dir


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]


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]", "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 --with flags, sorted and deduplicated with mcp[cli]."""
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"]


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"}}}})
)

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"}}}}))

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 should not clobber existing mcpServers entries."""
(config_dir / "claude_desktop_config.json").write_text(json.dumps({"mcpServers": {"other": {"command": "x"}}}))

assert update_claude_config(file_spec="s.py:app", server_name="s")

config = json.loads((config_dir / "claude_desktop_config.json").read_text())
assert set(config["mcpServers"]) == {"other", "s"}
assert config["mcpServers"]["other"] == {"command": "x"}


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
75 changes: 0 additions & 75 deletions tests/client/test_config.py

This file was deleted.

Loading
Loading