From ea35b7d462b05a87bd2efa7faefd5899b7981f89 Mon Sep 17 00:00:00 2001 From: Ethan John Date: Tue, 10 Jun 2025 13:42:48 -0700 Subject: [PATCH 1/3] Add stateless mode support for STDIO transport This commit implements support for the 'stateless' flag in STDIO transport, addressing issue #912. The changes include: - Adding a 'stateless_stdio' flag to FastMCP settings - Passing this flag to ServerSession when creating STDIO connections - Adding tests to verify stateless mode works correctly - Ensuring proper resource cleanup in tests This enables simpler CLI interaction patterns such as: echo '{"jsonrpc":"2.0","id":1,"method":"tools/call",...}' | python script.py Without requiring initialization messages, which is helpful for educational purposes and testing tools quickly via command line. Fixes #912 --- src/mcp/server/fastmcp/server.py | 4 ++ tests/server/test_session.py | 102 +++++++++++++++++++++++++++++++ tests/server/test_stdio.py | 38 ++++++++++++ 3 files changed, 144 insertions(+) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 577ed1b9a..a6d9d1d0a 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -87,6 +87,9 @@ class Settings(BaseSettings, Generic[LifespanResultT]): debug: bool = False log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + # STDIO settings + stateless_stdio: bool = False + # HTTP settings host: str = "127.0.0.1" port: int = 8000 @@ -597,6 +600,7 @@ async def run_stdio_async(self) -> None: read_stream, write_stream, self._mcp_server.create_initialization_options(), + stateless=self.settings.stateless_stdio ) async def run_sse_async(self, mount_path: str | None = None) -> None: diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 1375df12f..4f6c6bd8d 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -200,3 +200,105 @@ async def mock_client(): assert received_initialized assert received_protocol_version == "2024-11-05" + + +@pytest.mark.anyio +async def test_server_session_requires_initialization(): + """Test that ServerSession requires initialization before accepting requests.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[ + SessionMessage + ](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[ + SessionMessage | Exception + ](1) + + try: + init_options = InitializationOptions( + server_name="TestServer", + server_version="1.0", + capabilities=ServerCapabilities(), + ) + + async with ServerSession( + client_to_server_receive, + server_to_client_send, + init_options, + stateless=False, + ) as server_session: + request = types.ClientRequest( + root=types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams(name="test_tool", arguments={}), + ) + ) + + responder = RequestResponder( + request_id="test-id", + request_meta=None, # Using None instead of {} to fix type error + request=request, + session=server_session, + on_complete=lambda _: None, + ) + + with pytest.raises(RuntimeError) as excinfo: + await server_session._received_request(responder) + + assert "initialization" in str(excinfo.value).lower() + assert "before initialization was complete" in str(excinfo.value) + finally: + # Clean up the streams to prevent ResourceWarning + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +@pytest.mark.anyio +async def test_server_session_stateless_mode(): + """Test that ServerSession in stateless mode doesn't require initialization.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[ + SessionMessage + ](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[ + SessionMessage | Exception + ](1) + + try: + init_options = InitializationOptions( + server_name="TestServer", + server_version="1.0", + capabilities=ServerCapabilities(), + ) + + async with ServerSession( + client_to_server_receive, + server_to_client_send, + init_options, + stateless=True, + ) as server_session: + request = types.ClientRequest( + root=types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams(name="test_tool", arguments={}), + ) + ) + + responder = RequestResponder( + request_id="test-id", + request_meta=None, # Using None instead of {} to fix type error + request=request, + session=server_session, + on_complete=lambda _: None, + ) + + try: + await server_session._received_request(responder) + except RuntimeError as e: + if "initialization" in str(e).lower(): + pytest.fail(f"Unexpected initialization error in stateless mode: {e}") + finally: + # Clean up the streams to prevent ResourceWarning + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index c546a7167..586f224dd 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -1,8 +1,12 @@ import io +import tempfile +from pathlib import Path import anyio import pytest +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.server.stdio import stdio_server from mcp.shared.message import SessionMessage from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse @@ -68,3 +72,37 @@ async def test_stdio_server(): assert received_responses[1] == JSONRPCMessage( root=JSONRPCResponse(jsonrpc="2.0", id=4, result={}) ) + + +@pytest.mark.anyio +async def test_stateless_stdio(): + """Test that stateless stdio mode allows tool calls without initialization.""" + with tempfile.TemporaryDirectory() as temp_dir: + server_path = Path(temp_dir) / "server.py" + + with open(server_path, "w") as f: + f.write(""" +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("StatelessServer") +mcp.settings.stateless_stdio = True + +@mcp.tool() +def echo(message: str) -> str: + return f"Echo: {message}" + +if __name__ == "__main__": + mcp.run() +""") + + server_params = StdioServerParameters( + command="python", + args=[str(server_path)], + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + result = await session.call_tool("echo", {"message": "hello"}) + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert getattr(result.content[0], "text") == "Echo: hello" From e010fe090badb036cebc58a039693b14180abce5 Mon Sep 17 00:00:00 2001 From: Ethan John Date: Tue, 10 Jun 2025 15:01:35 -0700 Subject: [PATCH 2/3] Update stateless stdio support with example test script and test fixes --- src/mcp/server/fastmcp/server.py | 2 +- test_stateless.py | 16 ++++++++++++++++ tests/server/test_session.py | 3 ++- tests/server/test_stdio.py | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 test_stateless.py diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index a6d9d1d0a..60abdb8d9 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -600,7 +600,7 @@ async def run_stdio_async(self) -> None: read_stream, write_stream, self._mcp_server.create_initialization_options(), - stateless=self.settings.stateless_stdio + stateless=self.settings.stateless_stdio, ) async def run_sse_async(self, mount_path: str | None = None) -> None: diff --git a/test_stateless.py b/test_stateless.py new file mode 100644 index 000000000..177eccb08 --- /dev/null +++ b/test_stateless.py @@ -0,0 +1,16 @@ +from mcp.server.fastmcp import FastMCP + +# Create FastMCP server +mcp = FastMCP("StatelessTest") + + +# Register a simple echo tool +@mcp.tool() +def echo(message: str) -> str: + """Echo a message back to the client.""" + return f"Echo: {message}" + + +if __name__ == "__main__": + # Run in STDIO mode + mcp.run() diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 4f6c6bd8d..422c50de1 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -295,7 +295,8 @@ async def test_server_session_stateless_mode(): await server_session._received_request(responder) except RuntimeError as e: if "initialization" in str(e).lower(): - pytest.fail(f"Unexpected initialization error in stateless mode: {e}") + msg = f"Unexpected initialization error in stateless mode: {e}" + pytest.fail(msg) finally: # Clean up the streams to prevent ResourceWarning await server_to_client_send.aclose() diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 586f224dd..4f03057a5 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -79,7 +79,7 @@ async def test_stateless_stdio(): """Test that stateless stdio mode allows tool calls without initialization.""" with tempfile.TemporaryDirectory() as temp_dir: server_path = Path(temp_dir) / "server.py" - + with open(server_path, "w") as f: f.write(""" from mcp.server.fastmcp import FastMCP From 7216e0d8c9c2f5d507147007aef2168a325e73a2 Mon Sep 17 00:00:00 2001 From: Ethan John Date: Tue, 10 Jun 2025 15:02:48 -0700 Subject: [PATCH 3/3] Remove test_stateless.py from PR --- test_stateless.py | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 test_stateless.py diff --git a/test_stateless.py b/test_stateless.py deleted file mode 100644 index 177eccb08..000000000 --- a/test_stateless.py +++ /dev/null @@ -1,16 +0,0 @@ -from mcp.server.fastmcp import FastMCP - -# Create FastMCP server -mcp = FastMCP("StatelessTest") - - -# Register a simple echo tool -@mcp.tool() -def echo(message: str) -> str: - """Echo a message back to the client.""" - return f"Echo: {message}" - - -if __name__ == "__main__": - # Run in STDIO mode - mcp.run()