Skip to content

Commit f3ace1e

Browse files
committed
Merge branch 'ihrpr/shttp' into ihrpr/shttp-docs
2 parents 472dc0f + ee70cb1 commit f3ace1e

File tree

4 files changed

+141
-4
lines changed

4 files changed

+141
-4
lines changed

src/mcp/cli/claude.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import os
5+
import shutil
56
import sys
67
from pathlib import Path
78
from typing import Any
@@ -30,6 +31,16 @@ def get_claude_config_path() -> Path | None:
3031
return path
3132
return None
3233

34+
def get_uv_path() -> str:
35+
"""Get the full path to the uv executable."""
36+
uv_path = shutil.which("uv")
37+
if not uv_path:
38+
logger.error(
39+
"uv executable not found in PATH, falling back to 'uv'. "
40+
"Please ensure uv is installed and in your PATH"
41+
)
42+
return "uv" # Fall back to just "uv" if not found
43+
return uv_path
3344

3445
def update_claude_config(
3546
file_spec: str,
@@ -54,6 +65,7 @@ def update_claude_config(
5465
Claude Desktop may not be installed or properly set up.
5566
"""
5667
config_dir = get_claude_config_path()
68+
uv_path = get_uv_path()
5769
if not config_dir:
5870
raise RuntimeError(
5971
"Claude Desktop config directory not found. Please ensure Claude Desktop"
@@ -117,7 +129,7 @@ def update_claude_config(
117129
# Add fastmcp run command
118130
args.extend(["mcp", "run", file_spec])
119131

120-
server_config: dict[str, Any] = {"command": "uv", "args": args}
132+
server_config: dict[str, Any] = {"command": uv_path, "args": args}
121133

122134
# Add environment variables if specified
123135
if env_vars:

src/mcp/server/streamable_http_manager.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import contextlib
66
import logging
7+
import threading
78
from collections.abc import AsyncIterator
89
from http import HTTPStatus
910
from typing import Any
@@ -37,6 +38,10 @@ class StreamableHTTPSessionManager:
3738
3. Connection management and lifecycle
3839
4. Request handling and transport setup
3940
41+
Important: Only one StreamableHTTPSessionManager instance should be created
42+
per application. The instance cannot be reused after its run() context has
43+
completed. If you need to restart the manager, create a new instance.
44+
4045
Args:
4146
app: The MCP server instance
4247
event_store: Optional event store for resumability support.
@@ -67,6 +72,9 @@ def __init__(
6772

6873
# The task group will be set during lifespan
6974
self._task_group = None
75+
# Thread-safe tracking of run() calls
76+
self._run_lock = threading.Lock()
77+
self._has_started = False
7078

7179
@contextlib.asynccontextmanager
7280
async def run(self) -> AsyncIterator[None]:
@@ -75,13 +83,26 @@ async def run(self) -> AsyncIterator[None]:
7583
7684
This creates and manages the task group for all session operations.
7785
86+
Important: This method can only be called once per instance. The same
87+
StreamableHTTPSessionManager instance cannot be reused after this
88+
context manager exits. Create a new instance if you need to restart.
89+
7890
Use this in the lifespan context manager of your Starlette app:
7991
8092
@contextlib.asynccontextmanager
8193
async def lifespan(app: Starlette) -> AsyncIterator[None]:
8294
async with session_manager.run():
8395
yield
8496
"""
97+
# Thread-safe check to ensure run() is only called once
98+
with self._run_lock:
99+
if self._has_started:
100+
raise RuntimeError(
101+
"StreamableHTTPSessionManager .run() can only be called "
102+
"once per instance. Create a new instance if you need to run again."
103+
)
104+
self._has_started = True
105+
85106
async with anyio.create_task_group() as tg:
86107
# Store the task group for later use
87108
self._task_group = tg
@@ -113,9 +134,7 @@ async def handle_request(
113134
send: ASGI send function
114135
"""
115136
if self._task_group is None:
116-
raise RuntimeError(
117-
"Task group is not initialized. Make sure to use the run()."
118-
)
137+
raise RuntimeError("Task group is not initialized. Make sure to use run().")
119138

120139
# Dispatch to the appropriate handler
121140
if self.stateless:

tests/client/test_config.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,28 @@ def test_command_execution(mock_config_path: Path):
4848

4949
assert result.returncode == 0
5050
assert "usage" in result.stdout.lower()
51+
52+
53+
def test_absolute_uv_path(mock_config_path: Path):
54+
"""Test that the absolute path to uv is used when available."""
55+
# Mock the shutil.which function to return a fake path
56+
mock_uv_path = "/usr/local/bin/uv"
57+
58+
with patch("mcp.cli.claude.get_uv_path", return_value=mock_uv_path):
59+
# Setup
60+
server_name = "test_server"
61+
file_spec = "test_server.py:app"
62+
63+
# Update config
64+
success = update_claude_config(file_spec=file_spec, server_name=server_name)
65+
assert success
66+
67+
# Read the generated config
68+
config_file = mock_config_path / "claude_desktop_config.json"
69+
config = json.loads(config_file.read_text())
70+
71+
# Verify the command is the absolute path
72+
server_config = config["mcpServers"][server_name]
73+
command = server_config["command"]
74+
75+
assert command == mock_uv_path
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Tests for StreamableHTTPSessionManager."""
2+
3+
import anyio
4+
import pytest
5+
6+
from mcp.server.lowlevel import Server
7+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
8+
9+
10+
@pytest.mark.anyio
11+
async def test_run_can_only_be_called_once():
12+
"""Test that run() can only be called once per instance."""
13+
app = Server("test-server")
14+
manager = StreamableHTTPSessionManager(app=app)
15+
16+
# First call should succeed
17+
async with manager.run():
18+
pass
19+
20+
# Second call should raise RuntimeError
21+
with pytest.raises(RuntimeError) as excinfo:
22+
async with manager.run():
23+
pass
24+
25+
assert (
26+
"StreamableHTTPSessionManager .run() can only be called once per instance"
27+
in str(excinfo.value)
28+
)
29+
30+
31+
@pytest.mark.anyio
32+
async def test_run_prevents_concurrent_calls():
33+
"""Test that concurrent calls to run() are prevented."""
34+
app = Server("test-server")
35+
manager = StreamableHTTPSessionManager(app=app)
36+
37+
errors = []
38+
39+
async def try_run():
40+
try:
41+
async with manager.run():
42+
# Simulate some work
43+
await anyio.sleep(0.1)
44+
except RuntimeError as e:
45+
errors.append(e)
46+
47+
# Try to run concurrently
48+
async with anyio.create_task_group() as tg:
49+
tg.start_soon(try_run)
50+
tg.start_soon(try_run)
51+
52+
# One should succeed, one should fail
53+
assert len(errors) == 1
54+
assert (
55+
"StreamableHTTPSessionManager .run() can only be called once per instance"
56+
in str(errors[0])
57+
)
58+
59+
60+
@pytest.mark.anyio
61+
async def test_handle_request_without_run_raises_error():
62+
"""Test that handle_request raises error if run() hasn't been called."""
63+
app = Server("test-server")
64+
manager = StreamableHTTPSessionManager(app=app)
65+
66+
# Mock ASGI parameters
67+
scope = {"type": "http", "method": "POST", "path": "/test"}
68+
69+
async def receive():
70+
return {"type": "http.request", "body": b""}
71+
72+
async def send(message):
73+
pass
74+
75+
# Should raise error because run() hasn't been called
76+
with pytest.raises(RuntimeError) as excinfo:
77+
await manager.handle_request(scope, receive, send)
78+
79+
assert "Task group is not initialized. Make sure to use run()." in str(
80+
excinfo.value
81+
)

0 commit comments

Comments
 (0)