Skip to content

Commit 58c5e72

Browse files
authored
SSE FastMCP - do not go though auth when it's not needed (#619)
1 parent 83968b5 commit 58c5e72

File tree

2 files changed

+146
-11
lines changed

2 files changed

+146
-11
lines changed

src/mcp/server/fastmcp/server.py

+34-11
Original file line numberDiff line numberDiff line change
@@ -625,19 +625,42 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):
625625
)
626626
)
627627

628-
routes.append(
629-
Route(
630-
self.settings.sse_path,
631-
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
632-
methods=["GET"],
628+
# When auth is not configured, we shouldn't require auth
629+
if self._auth_server_provider:
630+
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
631+
routes.append(
632+
Route(
633+
self.settings.sse_path,
634+
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
635+
methods=["GET"],
636+
)
633637
)
634-
)
635-
routes.append(
636-
Mount(
637-
self.settings.message_path,
638-
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
638+
routes.append(
639+
Mount(
640+
self.settings.message_path,
641+
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
642+
)
643+
)
644+
else:
645+
# Auth is disabled, no need for RequireAuthMiddleware
646+
# Since handle_sse is an ASGI app, we need to create a compatible endpoint
647+
async def sse_endpoint(request: Request) -> None:
648+
# Convert the Starlette request to ASGI parameters
649+
await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]
650+
651+
routes.append(
652+
Route(
653+
self.settings.sse_path,
654+
endpoint=sse_endpoint,
655+
methods=["GET"],
656+
)
657+
)
658+
routes.append(
659+
Mount(
660+
self.settings.message_path,
661+
app=sse.handle_post_message,
662+
)
639663
)
640-
)
641664
# mount these routes last, so they have the lowest route matching precedence
642665
routes.extend(self._custom_starlette_routes)
643666

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Integration tests for FastMCP server functionality.
3+
4+
These tests validate the proper functioning of FastMCP in various configurations,
5+
including with and without authentication.
6+
"""
7+
8+
import multiprocessing
9+
import socket
10+
import time
11+
from collections.abc import Generator
12+
13+
import pytest
14+
import uvicorn
15+
16+
from mcp.client.session import ClientSession
17+
from mcp.client.sse import sse_client
18+
from mcp.server.fastmcp import FastMCP
19+
from mcp.types import InitializeResult, TextContent
20+
21+
22+
@pytest.fixture
23+
def server_port() -> int:
24+
"""Get a free port for testing."""
25+
with socket.socket() as s:
26+
s.bind(("127.0.0.1", 0))
27+
return s.getsockname()[1]
28+
29+
30+
@pytest.fixture
31+
def server_url(server_port: int) -> str:
32+
"""Get the server URL for testing."""
33+
return f"http://127.0.0.1:{server_port}"
34+
35+
36+
# Create a function to make the FastMCP server app
37+
def make_fastmcp_app():
38+
"""Create a FastMCP server without auth settings."""
39+
from starlette.applications import Starlette
40+
41+
mcp = FastMCP(name="NoAuthServer")
42+
43+
# Add a simple tool
44+
@mcp.tool(description="A simple echo tool")
45+
def echo(message: str) -> str:
46+
return f"Echo: {message}"
47+
48+
# Create the SSE app
49+
app: Starlette = mcp.sse_app()
50+
51+
return mcp, app
52+
53+
54+
def run_server(server_port: int) -> None:
55+
"""Run the server."""
56+
_, app = make_fastmcp_app()
57+
server = uvicorn.Server(
58+
config=uvicorn.Config(
59+
app=app, host="127.0.0.1", port=server_port, log_level="error"
60+
)
61+
)
62+
print(f"Starting server on port {server_port}")
63+
server.run()
64+
65+
66+
@pytest.fixture()
67+
def server(server_port: int) -> Generator[None, None, None]:
68+
"""Start the server in a separate process and clean up after the test."""
69+
proc = multiprocessing.Process(target=run_server, args=(server_port,), daemon=True)
70+
print("Starting server process")
71+
proc.start()
72+
73+
# Wait for server to be running
74+
max_attempts = 20
75+
attempt = 0
76+
print("Waiting for server to start")
77+
while attempt < max_attempts:
78+
try:
79+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
80+
s.connect(("127.0.0.1", server_port))
81+
break
82+
except ConnectionRefusedError:
83+
time.sleep(0.1)
84+
attempt += 1
85+
else:
86+
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
87+
88+
yield
89+
90+
print("Killing server")
91+
proc.kill()
92+
proc.join(timeout=2)
93+
if proc.is_alive():
94+
print("Server process failed to terminate")
95+
96+
97+
@pytest.mark.anyio
98+
async def test_fastmcp_without_auth(server: None, server_url: str) -> None:
99+
"""Test that FastMCP works when auth settings are not provided."""
100+
# Connect to the server
101+
async with sse_client(server_url + "/sse") as streams:
102+
async with ClientSession(*streams) as session:
103+
# Test initialization
104+
result = await session.initialize()
105+
assert isinstance(result, InitializeResult)
106+
assert result.serverInfo.name == "NoAuthServer"
107+
108+
# Test that we can call tools without authentication
109+
tool_result = await session.call_tool("echo", {"message": "hello"})
110+
assert len(tool_result.content) == 1
111+
assert isinstance(tool_result.content[0], TextContent)
112+
assert tool_result.content[0].text == "Echo: hello"

0 commit comments

Comments
 (0)