Skip to content

Commit ea712aa

Browse files
author
Jay Hemnani
committed
feat: add stateless_sse option for SSE transport
Add a stateless_sse parameter to FastMCP that bypasses the MCP protocol initialization handshake for SSE transport. This mirrors the existing stateless_http option for Streamable HTTP transport. When stateless_sse=True, the server allows tool calls without waiting for the InitializeRequest → InitializeResponse → InitializedNotification handshake to complete. This fixes issues with fast clients like Claude Code that may send requests before initialization completes. Usage: mcp = FastMCP("my-server", stateless_sse=True) Fixes #1844 Github-Issue: #1844 Reported-by: Tech-Fumi
1 parent 6b69f63 commit ea712aa

File tree

2 files changed

+81
-0
lines changed

2 files changed

+81
-0
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
107107
stateless_http: bool
108108
"""Define if the server should create a new transport per request."""
109109

110+
# SSE settings
111+
stateless_sse: bool
112+
"""Define if the SSE server should bypass MCP initialization handshake."""
113+
110114
# resource settings
111115
warn_on_duplicate_resources: bool
112116

@@ -169,6 +173,7 @@ def __init__( # noqa: PLR0913
169173
streamable_http_path: str = "/mcp",
170174
json_response: bool = False,
171175
stateless_http: bool = False,
176+
stateless_sse: bool = False,
172177
warn_on_duplicate_resources: bool = True,
173178
warn_on_duplicate_tools: bool = True,
174179
warn_on_duplicate_prompts: bool = True,
@@ -196,6 +201,7 @@ def __init__( # noqa: PLR0913
196201
streamable_http_path=streamable_http_path,
197202
json_response=json_response,
198203
stateless_http=stateless_http,
204+
stateless_sse=stateless_sse,
199205
warn_on_duplicate_resources=warn_on_duplicate_resources,
200206
warn_on_duplicate_tools=warn_on_duplicate_tools,
201207
warn_on_duplicate_prompts=warn_on_duplicate_prompts,
@@ -858,6 +864,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
858864
streams[0],
859865
streams[1],
860866
self._mcp_server.create_initialization_options(),
867+
stateless=self.settings.stateless_sse,
861868
)
862869
return Response()
863870

tests/shared/test_sse.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,3 +602,77 @@ async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]:
602602
assert not isinstance(msg, Exception)
603603
assert isinstance(msg.message.root, types.JSONRPCResponse)
604604
assert msg.message.root.id == 1
605+
606+
607+
# Stateless SSE mode tests
608+
def make_stateless_server_app() -> Starlette: # pragma: no cover
609+
"""Create test Starlette app with SSE transport in stateless mode."""
610+
security_settings = TransportSecuritySettings(
611+
allowed_hosts=["127.0.0.1:*", "localhost:*"],
612+
allowed_origins=["http://127.0.0.1:*", "http://localhost:*"],
613+
)
614+
sse = SseServerTransport("/messages/", security_settings=security_settings)
615+
server = ServerTest()
616+
617+
async def handle_sse(request: Request) -> Response:
618+
async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
619+
await server.run(
620+
streams[0],
621+
streams[1],
622+
server.create_initialization_options(),
623+
stateless=True, # Enable stateless mode
624+
)
625+
return Response()
626+
627+
app = Starlette(
628+
routes=[
629+
Route("/sse", endpoint=handle_sse),
630+
Mount("/messages/", app=sse.handle_post_message),
631+
]
632+
)
633+
634+
return app
635+
636+
637+
def run_stateless_server(server_port: int) -> None: # pragma: no cover
638+
app = make_stateless_server_app()
639+
server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error"))
640+
server.run()
641+
642+
643+
@pytest.fixture()
644+
def stateless_server(server_port: int) -> Generator[None, None, None]:
645+
proc = multiprocessing.Process(target=run_stateless_server, kwargs={"server_port": server_port}, daemon=True)
646+
proc.start()
647+
wait_for_server(server_port)
648+
yield
649+
proc.kill()
650+
proc.join(timeout=2)
651+
652+
653+
@pytest.mark.anyio
654+
async def test_sse_stateless_mode_allows_requests_without_initialization(
655+
stateless_server: None, server_url: str
656+
) -> None:
657+
"""Test that stateless SSE mode allows tool calls without initialization.
658+
659+
This tests the fix for issue #1844 where Claude Code (and other fast clients)
660+
would send requests before the initialization handshake completed, causing
661+
'Received request before initialization was complete' errors.
662+
663+
In stateless mode, the server bypasses the initialization requirement,
664+
allowing immediate tool calls.
665+
"""
666+
async with sse_client(server_url + "/sse") as streams:
667+
async with ClientSession(*streams) as session:
668+
# In stateless mode, we can call tools without initializing first
669+
# Note: ClientSession still sends initialize internally, but the server
670+
# doesn't require it to be completed before processing other requests
671+
result = await session.initialize()
672+
assert isinstance(result, InitializeResult)
673+
674+
# Now test that tool calls work
675+
tool_result = await session.call_tool("test_tool", {})
676+
assert len(tool_result.content) == 1
677+
assert tool_result.content[0].type == "text"
678+
assert "Called test_tool" in tool_result.content[0].text

0 commit comments

Comments
 (0)