Skip to content

Commit 013a295

Browse files
committed
integration test for stateless sttp
1 parent c9f686f commit 013a295

File tree

3 files changed

+129
-25
lines changed

3 files changed

+129
-25
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,13 @@ def run(
201201
if transport not in TRANSPORTS.__args__: # type: ignore
202202
raise ValueError(f"Unknown transport: {transport}")
203203

204-
if transport == "stdio":
205-
anyio.run(self.run_stdio_async)
206-
elif transport == "sse":
207-
anyio.run(self.run_sse_async)
208-
else: # transport == "streamable_http"
209-
anyio.run(self.run_streamable_http_async)
204+
match transport:
205+
case "stdio":
206+
anyio.run(self.run_stdio_async)
207+
case "sse":
208+
anyio.run(self.run_sse_async)
209+
case "streamable-http":
210+
anyio.run(self.run_streamable_http_async)
210211

211212
def _setup_handlers(self) -> None:
212213
"""Set up core MCP protocol handlers."""
@@ -748,10 +749,6 @@ async def handle_streamable_http(
748749
revocation_options=self.settings.auth.revocation_options,
749750
)
750751
)
751-
752-
# Add the StreamableHTTP endpoint
753-
if self._auth_server_provider:
754-
# Auth is enabled, wrap with RequireAuthMiddleware
755752
routes.append(
756753
Mount(
757754
self.settings.streamable_http_path,

src/mcp/server/streamable_http_manager.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ async def handle_request(
114114
"""
115115
if self._task_group is None:
116116
raise RuntimeError(
117-
"Task group is not initialized. Make sure to use the lifespan_hook."
117+
"Task group is not initialized. Make sure to use the run()."
118118
)
119119

120120
# Dispatch to the appropriate handler
@@ -145,14 +145,12 @@ async def _handle_stateless_request(
145145
event_store=None, # No event store in stateless mode
146146
)
147147

148-
# Run the server within the transport
149-
async with http_transport.connect() as streams:
150-
read_stream, write_stream = streams
151-
152-
# Start server in a new task
153-
async def run_stateless_server(
154-
*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED
155-
):
148+
# Start server in a new task
149+
async def run_stateless_server(
150+
*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED
151+
):
152+
async with http_transport.connect() as streams:
153+
read_stream, write_stream = streams
156154
task_status.started()
157155
await self.app.run(
158156
read_stream,
@@ -161,13 +159,13 @@ async def run_stateless_server(
161159
stateless=True,
162160
)
163161

164-
# Assert task group is not None for type checking
165-
assert self._task_group is not None
166-
# Start the server task
167-
await self._task_group.start(run_stateless_server)
162+
# Assert task group is not None for type checking
163+
assert self._task_group is not None
164+
# Start the server task
165+
await self._task_group.start(run_stateless_server)
168166

169-
# Handle the HTTP request and return the response
170-
await http_transport.handle_request(scope, receive, send)
167+
# Handle the HTTP request and return the response
168+
await http_transport.handle_request(scope, receive, send)
171169

172170
async def _handle_stateful_request(
173171
self,

tests/server/fastmcp/test_integration.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ def http_server_url(http_server_port: int) -> str:
4848
return f"http://127.0.0.1:{http_server_port}"
4949

5050

51+
@pytest.fixture
52+
def stateless_http_server_port() -> int:
53+
"""Get a free port for testing the stateless StreamableHTTP server."""
54+
with socket.socket() as s:
55+
s.bind(("127.0.0.1", 0))
56+
return s.getsockname()[1]
57+
58+
59+
@pytest.fixture
60+
def stateless_http_server_url(stateless_http_server_port: int) -> str:
61+
"""Get the stateless StreamableHTTP server URL for testing."""
62+
return f"http://127.0.0.1:{stateless_http_server_port}"
63+
64+
5165
# Create a function to make the FastMCP server app
5266
def make_fastmcp_app():
5367
"""Create a FastMCP server without auth settings."""
@@ -83,6 +97,23 @@ def echo(message: str) -> str:
8397
return mcp, app
8498

8599

100+
def make_fastmcp_stateless_http_app():
101+
"""Create a FastMCP server with stateless StreamableHTTP transport."""
102+
from starlette.applications import Starlette
103+
104+
mcp = FastMCP(name="StatelessServer", stateless_http=True)
105+
106+
# Add a simple tool
107+
@mcp.tool(description="A simple echo tool")
108+
def echo(message: str) -> str:
109+
return f"Echo: {message}"
110+
111+
# Create the StreamableHTTP app
112+
app: Starlette = mcp.streamable_http_app()
113+
114+
return mcp, app
115+
116+
86117
def run_server(server_port: int) -> None:
87118
"""Run the server."""
88119
_, app = make_fastmcp_app()
@@ -107,6 +138,18 @@ def run_streamable_http_server(server_port: int) -> None:
107138
server.run()
108139

109140

141+
def run_stateless_http_server(server_port: int) -> None:
142+
"""Run the stateless StreamableHTTP server."""
143+
_, app = make_fastmcp_stateless_http_app()
144+
server = uvicorn.Server(
145+
config=uvicorn.Config(
146+
app=app, host="127.0.0.1", port=server_port, log_level="error"
147+
)
148+
)
149+
print(f"Starting stateless StreamableHTTP server on port {server_port}")
150+
server.run()
151+
152+
110153
@pytest.fixture()
111154
def server(server_port: int) -> Generator[None, None, None]:
112155
"""Start the server in a separate process and clean up after the test."""
@@ -173,6 +216,45 @@ def streamable_http_server(http_server_port: int) -> Generator[None, None, None]
173216
print("StreamableHTTP server process failed to terminate")
174217

175218

219+
@pytest.fixture()
220+
def stateless_http_server(
221+
stateless_http_server_port: int,
222+
) -> Generator[None, None, None]:
223+
"""Start the stateless StreamableHTTP server in a separate process."""
224+
proc = multiprocessing.Process(
225+
target=run_stateless_http_server,
226+
args=(stateless_http_server_port,),
227+
daemon=True,
228+
)
229+
print("Starting stateless StreamableHTTP server process")
230+
proc.start()
231+
232+
# Wait for server to be running
233+
max_attempts = 20
234+
attempt = 0
235+
print("Waiting for stateless StreamableHTTP server to start")
236+
while attempt < max_attempts:
237+
try:
238+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
239+
s.connect(("127.0.0.1", stateless_http_server_port))
240+
break
241+
except ConnectionRefusedError:
242+
time.sleep(0.1)
243+
attempt += 1
244+
else:
245+
raise RuntimeError(
246+
f"Stateless server failed to start after {max_attempts} attempts"
247+
)
248+
249+
yield
250+
251+
print("Killing stateless StreamableHTTP server")
252+
proc.kill()
253+
proc.join(timeout=2)
254+
if proc.is_alive():
255+
print("Stateless StreamableHTTP server process failed to terminate")
256+
257+
176258
@pytest.mark.anyio
177259
async def test_fastmcp_without_auth(server: None, server_url: str) -> None:
178260
"""Test that FastMCP works when auth settings are not provided."""
@@ -214,3 +296,30 @@ async def test_fastmcp_streamable_http(
214296
assert len(tool_result.content) == 1
215297
assert isinstance(tool_result.content[0], TextContent)
216298
assert tool_result.content[0].text == "Echo: hello"
299+
300+
301+
@pytest.mark.anyio
302+
async def test_fastmcp_stateless_streamable_http(
303+
stateless_http_server: None, stateless_http_server_url: str
304+
) -> None:
305+
"""Test that FastMCP works with stateless StreamableHTTP transport."""
306+
# Connect to the server using StreamableHTTP
307+
async with streamablehttp_client(stateless_http_server_url + "/mcp") as (
308+
read_stream,
309+
write_stream,
310+
_,
311+
):
312+
async with ClientSession(read_stream, write_stream) as session:
313+
result = await session.initialize()
314+
assert isinstance(result, InitializeResult)
315+
assert result.serverInfo.name == "StatelessServer"
316+
tool_result = await session.call_tool("echo", {"message": "hello"})
317+
assert len(tool_result.content) == 1
318+
assert isinstance(tool_result.content[0], TextContent)
319+
assert tool_result.content[0].text == "Echo: hello"
320+
321+
for i in range(3):
322+
tool_result = await session.call_tool("echo", {"message": f"test_{i}"})
323+
assert len(tool_result.content) == 1
324+
assert isinstance(tool_result.content[0], TextContent)
325+
assert tool_result.content[0].text == f"Echo: test_{i}"

0 commit comments

Comments
 (0)