From 17f5444827051b261bc9250358f91d0997f8dd81 Mon Sep 17 00:00:00 2001 From: Yuri Kunash Date: Thu, 10 Jul 2025 10:25:21 +0800 Subject: [PATCH 1/7] Remove submounting for streamable http app --- src/mcp/server/fastmcp/server.py | 14 ++++++-------- src/mcp/server/streamable_http.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index c6c0cb5a3..cc539f700 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -44,7 +44,7 @@ from mcp.server.session import ServerSession, ServerSessionT from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server -from mcp.server.streamable_http import EventStore +from mcp.server.streamable_http import EventStore, StreamableHTTPASGIApp from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.context import LifespanContextT, RequestContext, RequestT @@ -828,7 +828,6 @@ async def sse_endpoint(request: Request) -> Response: def streamable_http_app(self) -> Starlette: """Return an instance of the StreamableHTTP server app.""" from starlette.middleware import Middleware - from starlette.routing import Mount # Create session manager on first call (lazy initialization) if self._session_manager is None: @@ -841,8 +840,7 @@ def streamable_http_app(self) -> Starlette: ) # Create the ASGI handler - async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None: - await self.session_manager.handle_request(scope, receive, send) + streamable_http_app = StreamableHTTPASGIApp(self._session_manager) # Create routes routes: list[Route | Mount] = [] @@ -889,17 +887,17 @@ async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> ) routes.append( - Mount( + Route( self.settings.streamable_http_path, - app=RequireAuthMiddleware(handle_streamable_http, required_scopes, resource_metadata_url), + endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url), ) ) else: # Auth is disabled, no wrapper needed routes.append( - Mount( + Route( self.settings.streamable_http_path, - app=handle_streamable_http, + endpoint=streamable_http_app, ) ) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index e9ac4b6ce..a18834494 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -28,6 +28,7 @@ TransportSecurityMiddleware, TransportSecuritySettings, ) +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( @@ -896,3 +897,15 @@ async def message_router(): except Exception as e: # During cleanup, we catch all exceptions since streams might be in various states logger.debug(f"Error closing streams: {e}") + + +class StreamableHTTPASGIApp: + """ + ASGI application for StreamableHTTP server transport. + """ + + def __init__(self, session_manager: StreamableHTTPSessionManager): + self.session_manager = session_manager + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await self.session_manager.handle_request(scope, receive, send) From eca75ea80b8119627c05839b8c646eb402ba8445 Mon Sep 17 00:00:00 2001 From: Yuri Kunash Date: Thu, 10 Jul 2025 11:39:12 +0800 Subject: [PATCH 2/7] Remove circular import --- src/mcp/server/fastmcp/server.py | 12 +++++++++++- src/mcp/server/streamable_http.py | 15 +-------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index b64347dbd..a05883bb6 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -44,7 +44,7 @@ from mcp.server.session import ServerSession, ServerSessionT from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server -from mcp.server.streamable_http import EventStore, StreamableHTTPASGIApp +from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.context import LifespanContextT, RequestContext, RequestT @@ -969,6 +969,16 @@ async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) - logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) +class StreamableHTTPASGIApp: + """ + ASGI application for Streamable HTTP server transport. + """ + + def __init__(self, session_manager: StreamableHTTPSessionManager): + self.session_manager = session_manager + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await self.session_manager.handle_request(scope, receive, send) class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): """Context object providing access to MCP capabilities. diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index e517958b2..cb56adbe1 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -28,7 +28,6 @@ TransportSecurityMiddleware, TransportSecuritySettings, ) -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( @@ -901,16 +900,4 @@ async def message_router(): await write_stream.aclose() except Exception as e: # During cleanup, we catch all exceptions since streams might be in various states - logger.debug(f"Error closing streams: {e}") - - -class StreamableHTTPASGIApp: - """ - ASGI application for StreamableHTTP server transport. - """ - - def __init__(self, session_manager: StreamableHTTPSessionManager): - self.session_manager = session_manager - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - await self.session_manager.handle_request(scope, receive, send) + logger.debug(f"Error closing streams: {e}") \ No newline at end of file From 6faa20d49dd6fc06498f5160f7661e693703c336 Mon Sep 17 00:00:00 2001 From: Yuri Kunash Date: Thu, 10 Jul 2025 11:40:45 +0800 Subject: [PATCH 3/7] Add empty line --- src/mcp/server/streamable_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index cb56adbe1..10a388b6c 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -900,4 +900,4 @@ async def message_router(): await write_stream.aclose() except Exception as e: # During cleanup, we catch all exceptions since streams might be in various states - logger.debug(f"Error closing streams: {e}") \ No newline at end of file + logger.debug(f"Error closing streams: {e}") From 9cb54415318b25d76f97f8ae2c9c71338415b8c3 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Jul 2025 10:56:49 +0100 Subject: [PATCH 4/7] test and ruff --- src/mcp/server/fastmcp/server.py | 2 ++ tests/server/fastmcp/test_server.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index a05883bb6..2fe7c1224 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -969,6 +969,7 @@ async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) - logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) + class StreamableHTTPASGIApp: """ ASGI application for Streamable HTTP server transport. @@ -980,6 +981,7 @@ def __init__(self, session_manager: StreamableHTTPSessionManager): async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.session_manager.handle_request(scope, receive, send) + class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): """Context object providing access to MCP capabilities. diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 96cece9c3..e0570a8b8 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -1072,3 +1072,15 @@ def prompt_fn(name: str) -> str: async with client_session(mcp._mcp_server) as client: with pytest.raises(McpError, match="Missing required arguments"): await client.get_prompt("prompt_fn") + + def test_streamable_http_no_redirect(self): + """Test that /mcp endpoint does not cause 307 redirect (PR #1115).""" + from starlette.testclient import TestClient + + mcp = FastMCP("test-redirect") + app = mcp.streamable_http_app() + + with TestClient(app, raise_server_exceptions=False) as client: + # Test POST to /mcp - should NOT redirect + response = client.post("/mcp", json={"test": "data"}, follow_redirects=False) + assert response.status_code != 307 From 8ad5fc364029be017f9f812f0fe36f40b36a1763 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Jul 2025 11:11:19 +0100 Subject: [PATCH 5/7] filter out deprecation warning for lowest version in test --- tests/server/fastmcp/test_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index e0570a8b8..e75258b2c 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -1073,6 +1073,7 @@ def prompt_fn(name: str) -> str: with pytest.raises(McpError, match="Missing required arguments"): await client.get_prompt("prompt_fn") + @pytest.mark.filterwarnings("ignore::DeprecationWarning:httpx") def test_streamable_http_no_redirect(self): """Test that /mcp endpoint does not cause 307 redirect (PR #1115).""" from starlette.testclient import TestClient From bccd718c3be8bf3b1ed70dd01e2e365bc819335a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Jul 2025 11:25:46 +0100 Subject: [PATCH 6/7] fix test --- tests/server/fastmcp/test_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index e75258b2c..fc13f48c4 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -1073,8 +1073,9 @@ def prompt_fn(name: str) -> str: with pytest.raises(McpError, match="Missing required arguments"): await client.get_prompt("prompt_fn") + @pytest.mark.anyio @pytest.mark.filterwarnings("ignore::DeprecationWarning:httpx") - def test_streamable_http_no_redirect(self): + async def test_streamable_http_no_redirect(self): """Test that /mcp endpoint does not cause 307 redirect (PR #1115).""" from starlette.testclient import TestClient From 171dc5c772abb109a689aadb915f7b1fcf595519 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Jul 2025 11:46:07 +0100 Subject: [PATCH 7/7] remove testclient --- tests/server/fastmcp/test_server.py | 31 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index fc13f48c4..a9e0d182a 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -1073,16 +1073,21 @@ def prompt_fn(name: str) -> str: with pytest.raises(McpError, match="Missing required arguments"): await client.get_prompt("prompt_fn") - @pytest.mark.anyio - @pytest.mark.filterwarnings("ignore::DeprecationWarning:httpx") - async def test_streamable_http_no_redirect(self): - """Test that /mcp endpoint does not cause 307 redirect (PR #1115).""" - from starlette.testclient import TestClient - - mcp = FastMCP("test-redirect") - app = mcp.streamable_http_app() - - with TestClient(app, raise_server_exceptions=False) as client: - # Test POST to /mcp - should NOT redirect - response = client.post("/mcp", json={"test": "data"}, follow_redirects=False) - assert response.status_code != 307 + +def test_streamable_http_no_redirect() -> None: + """Test that streamable HTTP routes are correctly configured.""" + mcp = FastMCP() + app = mcp.streamable_http_app() + + # Find routes by type - streamable_http_app creates Route objects, not Mount objects + streamable_routes = [ + r + for r in app.routes + if isinstance(r, Route) and hasattr(r, "path") and r.path == mcp.settings.streamable_http_path + ] + + # Verify routes exist + assert len(streamable_routes) == 1, "Should have one streamable route" + + # Verify path values + assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp"