From 0bf3521b2c1003af500474defe561651dbd1ee85 Mon Sep 17 00:00:00 2001 From: 633WHU Date: Thu, 22 May 2025 17:00:41 +0800 Subject: [PATCH 01/26] Update streamable_http_path to /mcp/ to avoid 307 Temporary Redirect --- src/mcp/server/fastmcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 3282baae6..2b3f96688 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -92,7 +92,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]): mount_path: str = "/" # Mount path (e.g. "/github", defaults to root path) sse_path: str = "/sse" message_path: str = "/messages/" - streamable_http_path: str = "/mcp" + streamable_http_path: str = "/mcp/" # StreamableHTTP settings json_response: bool = False From d89e7370721f7911b75c797d61a188f521446394 Mon Sep 17 00:00:00 2001 From: 633WHU Date: Mon, 26 May 2025 10:58:33 +0800 Subject: [PATCH 02/26] Streamable HTTP Trailing Slash Compatibility --- src/mcp/server/fastmcp/server.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 2b3f96688..17c930636 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -92,7 +92,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]): mount_path: str = "/" # Mount path (e.g. "/github", defaults to root path) sse_path: str = "/sse" message_path: str = "/messages/" - streamable_http_path: str = "/mcp/" + streamable_http_path: str = "/mcp" # StreamableHTTP settings json_response: bool = False @@ -830,6 +830,18 @@ async def handle_streamable_http( ) ) + # Always mount both /mcp and /mcp/ for full compatibility, regardless of default + _main_path = self.settings.streamable_http_path + if _main_path.endswith("/"): + _alt_path = _main_path.rstrip("/") + else: + _alt_path = _main_path + "/" + if _alt_path != _main_path: + if self._auth_server_provider: + routes.append(Mount(_alt_path, app=RequireAuthMiddleware(handle_streamable_http, required_scopes))) + else: + routes.append(Mount(_alt_path, app=handle_streamable_http)) + routes.extend(self._custom_starlette_routes) return Starlette( From 7d6e14ff2480ef032e6e409bf50e574a2c094c7f Mon Sep 17 00:00:00 2001 From: 633WHU Date: Mon, 26 May 2025 11:00:09 +0800 Subject: [PATCH 03/26] Streamable HTTP Trailing Slash Compatibility --- .../mcp_simple_streamablehttp_stateless/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py index bbf3dc64c..935e796b9 100644 --- a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py @@ -130,6 +130,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: debug=True, routes=[ Mount("/mcp", app=handle_streamable_http), + Mount("/mcp/", app=handle_streamable_http), ], lifespan=lifespan, ) From a84399b4a40ea5ee04f014a493cf94a6046ff466 Mon Sep 17 00:00:00 2001 From: 633WHU Date: Mon, 26 May 2025 11:00:44 +0800 Subject: [PATCH 04/26] Streamable HTTP Trailing Slash Compatibility --- .../simple-streamablehttp/mcp_simple_streamablehttp/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py index bf6f51e5c..41ee52de3 100644 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py @@ -158,6 +158,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: debug=True, routes=[ Mount("/mcp", app=handle_streamable_http), + Mount("/mcp/", app=handle_streamable_http), ], lifespan=lifespan, ) From 1a638adeb9c4fe58062edcff7b45773c34e80fa3 Mon Sep 17 00:00:00 2001 From: 633WHU Date: Mon, 26 May 2025 11:05:44 +0800 Subject: [PATCH 05/26] Streamable HTTP Trailing Slash Compatibility --- src/mcp/server/fastmcp/server.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 17c930636..eaab02b06 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -838,9 +838,22 @@ async def handle_streamable_http( _alt_path = _main_path + "/" if _alt_path != _main_path: if self._auth_server_provider: - routes.append(Mount(_alt_path, app=RequireAuthMiddleware(handle_streamable_http, required_scopes))) + routes.append( + Mount( + _alt_path, + app=RequireAuthMiddleware( + handle_streamable_http, + required_scopes, + ), + ) + ) else: - routes.append(Mount(_alt_path, app=handle_streamable_http)) + routes.append( + Mount( + _alt_path, + app=handle_streamable_http, + ) + ) routes.extend(self._custom_starlette_routes) From b3bf4c9e8b2356376adb21f777833dcae015172e Mon Sep 17 00:00:00 2001 From: 633WHU Date: Mon, 26 May 2025 11:09:47 +0800 Subject: [PATCH 06/26] Streamable HTTP Trailing Slash Compatibility --- .../mcp_simple_streamablehttp_stateless/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py index 935e796b9..bbf3dc64c 100644 --- a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py @@ -130,7 +130,6 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: debug=True, routes=[ Mount("/mcp", app=handle_streamable_http), - Mount("/mcp/", app=handle_streamable_http), ], lifespan=lifespan, ) From 645e1abf20f9eedf73f45a5acc43eb59bc7548f6 Mon Sep 17 00:00:00 2001 From: 633WHU Date: Mon, 26 May 2025 11:10:16 +0800 Subject: [PATCH 07/26] Streamable HTTP Trailing Slash Compatibility --- .../simple-streamablehttp/mcp_simple_streamablehttp/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py index 41ee52de3..bf6f51e5c 100644 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py @@ -158,7 +158,6 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: debug=True, routes=[ Mount("/mcp", app=handle_streamable_http), - Mount("/mcp/", app=handle_streamable_http), ], lifespan=lifespan, ) From 7ef5f8d458456db46dc37c83e9bd34f163b86f21 Mon Sep 17 00:00:00 2001 From: Vincenzo Maria Calandra <65887821+vectorstain@users.noreply.github.com> Date: Tue, 27 May 2025 11:05:18 +0200 Subject: [PATCH 08/26] Clean logic (#3) --- src/mcp/server/fastmcp/server.py | 51 +++++++++++++------------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index eaab02b06..ee0942382 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -790,6 +790,12 @@ async def handle_streamable_http( middleware: list[Middleware] = [] required_scopes = [] + # Always mount both /mcp and /mcp/ for full compatibility, regardless of default + # Verify that _main_path has root format -> /mcp + _main_path = self.settings.streamable_http_path.removesuffix("/") + # Format _alt_path so it ends with '/' -> /mcp/ + _alt_path = _main_path + "/" + # Add auth endpoints if auth provider is configured if self._auth_server_provider: assert self.settings.auth @@ -815,46 +821,29 @@ async def handle_streamable_http( revocation_options=self.settings.auth.revocation_options, ) ) - routes.append( + routes.extend([ Mount( - self.settings.streamable_http_path, + _main_path, app=RequireAuthMiddleware(handle_streamable_http, required_scopes), - ) + ), + Mount( + _alt_path, + app=RequireAuthMiddleware(handle_streamable_http, required_scopes), + )] ) else: # Auth is disabled, no wrapper needed - routes.append( + routes.extend([ Mount( - self.settings.streamable_http_path, + _main_path, app=handle_streamable_http, - ) + ), + Mount( + _alt_path, + app=handle_streamable_http, + )] ) - # Always mount both /mcp and /mcp/ for full compatibility, regardless of default - _main_path = self.settings.streamable_http_path - if _main_path.endswith("/"): - _alt_path = _main_path.rstrip("/") - else: - _alt_path = _main_path + "/" - if _alt_path != _main_path: - if self._auth_server_provider: - routes.append( - Mount( - _alt_path, - app=RequireAuthMiddleware( - handle_streamable_http, - required_scopes, - ), - ) - ) - else: - routes.append( - Mount( - _alt_path, - app=handle_streamable_http, - ) - ) - routes.extend(self._custom_starlette_routes) return Starlette( From 898fc88faae4e7e6ef136a204e73935f07f84624 Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 11:06:51 +0800 Subject: [PATCH 09/26] add unit test for Streamable HTTP Trailing Slash Compatibility --- tests/shared/test_streamable_http.py | 234 ++++++++++++++------------- 1 file changed, 119 insertions(+), 115 deletions(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index f1c7ef809..c3236e20f 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -598,137 +598,141 @@ def test_session_termination(basic_server, basic_server_url): def test_response(basic_server, basic_server_url): """Test response handling for a valid request.""" - mcp_url = f"{basic_server_url}/mcp" - response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json=INIT_REQUEST, - ) - assert response.status_code == 200 - - # Now terminate the session - session_id = response.headers.get(MCP_SESSION_ID_HEADER) - - # Try to use the terminated session - tools_response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - MCP_SESSION_ID_HEADER: session_id, # Use the session ID we got earlier - }, - json={"jsonrpc": "2.0", "method": "tools/list", "id": "tools-1"}, - stream=True, - ) - assert tools_response.status_code == 200 - assert tools_response.headers.get("Content-Type") == "text/event-stream" + mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"] + for mcp_url in mcp_urls: + response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 200 + + # Now terminate the session + session_id = response.headers.get(MCP_SESSION_ID_HEADER) + + # Try to use the terminated session + tools_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, # Use the session ID we got earlier + }, + json={"jsonrpc": "2.0", "method": "tools/list", "id": "tools-1"}, + stream=True, + ) + assert tools_response.status_code == 200 + assert tools_response.headers.get("Content-Type") == "text/event-stream" def test_json_response(json_response_server, json_server_url): """Test response handling when is_json_response_enabled is True.""" - mcp_url = f"{json_server_url}/mcp" - response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json=INIT_REQUEST, - ) - assert response.status_code == 200 - assert response.headers.get("Content-Type") == "application/json" + mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"] + for mcp_url in mcp_urls: + response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 200 + assert response.headers.get("Content-Type") == "application/json" def test_get_sse_stream(basic_server, basic_server_url): """Test establishing an SSE stream via GET request.""" # First, we need to initialize a session - mcp_url = f"{basic_server_url}/mcp" - init_response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json=INIT_REQUEST, - ) - assert init_response.status_code == 200 - - # Get the session ID - session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) - assert session_id is not None - - # Now attempt to establish an SSE stream via GET - get_response = requests.get( - mcp_url, - headers={ - "Accept": "text/event-stream", - MCP_SESSION_ID_HEADER: session_id, - }, - stream=True, - ) - - # Verify we got a successful response with the right content type - assert get_response.status_code == 200 - assert get_response.headers.get("Content-Type") == "text/event-stream" + mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"] + for mcp_url in mcp_urls: + init_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 + + # Get the session ID + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + assert session_id is not None + + # Now attempt to establish an SSE stream via GET + get_response = requests.get( + mcp_url, + headers={ + "Accept": "text/event-stream", + MCP_SESSION_ID_HEADER: session_id, + }, + stream=True, + ) - # Test that a second GET request gets rejected (only one stream allowed) - second_get = requests.get( - mcp_url, - headers={ - "Accept": "text/event-stream", - MCP_SESSION_ID_HEADER: session_id, - }, - stream=True, - ) + # Verify we got a successful response with the right content type + assert get_response.status_code == 200 + assert get_response.headers.get("Content-Type") == "text/event-stream" + + # Test that a second GET request gets rejected (only one stream allowed) + second_get = requests.get( + mcp_url, + headers={ + "Accept": "text/event-stream", + MCP_SESSION_ID_HEADER: session_id, + }, + stream=True, + ) - # Should get CONFLICT (409) since there's already a stream - # Note: This might fail if the first stream fully closed before this runs, - # but generally it should work in the test environment where it runs quickly - assert second_get.status_code == 409 + # Should get CONFLICT (409) since there's already a stream + # Note: This might fail if the first stream fully closed before this runs, + # but generally it should work in the test environment where it runs quickly + assert second_get.status_code == 409 def test_get_validation(basic_server, basic_server_url): """Test validation for GET requests.""" # First, we need to initialize a session - mcp_url = f"{basic_server_url}/mcp" - init_response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json=INIT_REQUEST, - ) - assert init_response.status_code == 200 - - # Get the session ID - session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) - assert session_id is not None - - # Test without Accept header - response = requests.get( - mcp_url, - headers={ - MCP_SESSION_ID_HEADER: session_id, - }, - stream=True, - ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text - - # Test with wrong Accept header - response = requests.get( - mcp_url, - headers={ - "Accept": "application/json", - MCP_SESSION_ID_HEADER: session_id, - }, - ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text + mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"] + for mcp_url in mcp_urls: + init_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 + + # Get the session ID + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + assert session_id is not None + + # Test without Accept header + response = requests.get( + mcp_url, + headers={ + MCP_SESSION_ID_HEADER: session_id, + }, + stream=True, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text + + # Test with wrong Accept header + response = requests.get( + mcp_url, + headers={ + "Accept": "application/json", + MCP_SESSION_ID_HEADER: session_id, + }, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text # Client-specific fixtures From 45a5cd8d33dd80af3fd5c544caa05745e207cbdb Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 11:18:17 +0800 Subject: [PATCH 10/26] add unit test for Streamable HTTP Trailing Slash Compatibility --- .../test_fastmcp_streamable_http_mount.py | 21 ++ tests/shared/test_streamable_http.py | 236 +++++++++--------- 2 files changed, 137 insertions(+), 120 deletions(-) create mode 100644 tests/server/test_fastmcp_streamable_http_mount.py diff --git a/tests/server/test_fastmcp_streamable_http_mount.py b/tests/server/test_fastmcp_streamable_http_mount.py new file mode 100644 index 000000000..e0b346003 --- /dev/null +++ b/tests/server/test_fastmcp_streamable_http_mount.py @@ -0,0 +1,21 @@ +"""Test FastMCP streamable_http_app mounts both /mcp and /mcp/ automatically.""" + +import pytest +from starlette.testclient import TestClient +from mcp.server.fastmcp import FastMCP + +@pytest.fixture +def fastmcp_app(): + mcp = FastMCP(name="TestServer") + app = mcp.streamable_http_app() + return app + +def test_streamable_http_mount_dual_paths(fastmcp_app): + client = TestClient(fastmcp_app) + for path in ["/mcp", "/mcp/"]: + # Should return 406 because Accept header is missing, but proves route exists + resp = client.post(path, json={"jsonrpc": "2.0", "method": "initialize", "id": 1}) + assert resp.status_code in (400, 406) # 406 Not Acceptable or 400 Bad Request + # Optionally, test GET as well + resp_get = client.get(path) + assert resp_get.status_code in (400, 406, 405) # 405 if GET not allowed diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index c3236e20f..40ca3d953 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -598,141 +598,137 @@ def test_session_termination(basic_server, basic_server_url): def test_response(basic_server, basic_server_url): """Test response handling for a valid request.""" - mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"] - for mcp_url in mcp_urls: - response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json=INIT_REQUEST, - ) - assert response.status_code == 200 - - # Now terminate the session - session_id = response.headers.get(MCP_SESSION_ID_HEADER) - - # Try to use the terminated session - tools_response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - MCP_SESSION_ID_HEADER: session_id, # Use the session ID we got earlier - }, - json={"jsonrpc": "2.0", "method": "tools/list", "id": "tools-1"}, - stream=True, - ) - assert tools_response.status_code == 200 - assert tools_response.headers.get("Content-Type") == "text/event-stream" + mcp_url = f"{basic_server_url}/mcp" + response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 200 + + # Now terminate the session + session_id = response.headers.get(MCP_SESSION_ID_HEADER) + + # Try to use the terminated session + tools_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, # Use the session ID we got earlier + }, + json={"jsonrpc": "2.0", "method": "tools/list", "id": "tools-1"}, + stream=True, + ) + assert tools_response.status_code == 200 + assert tools_response.headers.get("Content-Type") == "text/event-stream" def test_json_response(json_response_server, json_server_url): """Test response handling when is_json_response_enabled is True.""" - mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"] - for mcp_url in mcp_urls: - response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json=INIT_REQUEST, - ) - assert response.status_code == 200 - assert response.headers.get("Content-Type") == "application/json" + mcp_url = f"{json_server_url}/mcp" + response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 200 + assert response.headers.get("Content-Type") == "application/json" def test_get_sse_stream(basic_server, basic_server_url): """Test establishing an SSE stream via GET request.""" # First, we need to initialize a session - mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"] - for mcp_url in mcp_urls: - init_response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json=INIT_REQUEST, - ) - assert init_response.status_code == 200 - - # Get the session ID - session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) - assert session_id is not None - - # Now attempt to establish an SSE stream via GET - get_response = requests.get( - mcp_url, - headers={ - "Accept": "text/event-stream", - MCP_SESSION_ID_HEADER: session_id, - }, - stream=True, - ) + mcp_url = f"{basic_server_url}/mcp" + init_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 - # Verify we got a successful response with the right content type - assert get_response.status_code == 200 - assert get_response.headers.get("Content-Type") == "text/event-stream" - - # Test that a second GET request gets rejected (only one stream allowed) - second_get = requests.get( - mcp_url, - headers={ - "Accept": "text/event-stream", - MCP_SESSION_ID_HEADER: session_id, - }, - stream=True, - ) + # Get the session ID + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + assert session_id is not None - # Should get CONFLICT (409) since there's already a stream - # Note: This might fail if the first stream fully closed before this runs, - # but generally it should work in the test environment where it runs quickly - assert second_get.status_code == 409 + # Now attempt to establish an SSE stream via GET + get_response = requests.get( + mcp_url, + headers={ + "Accept": "text/event-stream", + MCP_SESSION_ID_HEADER: session_id, + }, + stream=True, + ) + + # Verify we got a successful response with the right content type + assert get_response.status_code == 200 + assert get_response.headers.get("Content-Type") == "text/event-stream" + + # Test that a second GET request gets rejected (only one stream allowed) + second_get = requests.get( + mcp_url, + headers={ + "Accept": "text/event-stream", + MCP_SESSION_ID_HEADER: session_id, + }, + stream=True, + ) + + # Should get CONFLICT (409) since there's already a stream + # Note: This might fail if the first stream fully closed before this runs, + # but generally it should work in the test environment where it runs quickly + assert second_get.status_code == 409 def test_get_validation(basic_server, basic_server_url): """Test validation for GET requests.""" # First, we need to initialize a session - mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"] - for mcp_url in mcp_urls: - init_response = requests.post( - mcp_url, - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json=INIT_REQUEST, - ) - assert init_response.status_code == 200 - - # Get the session ID - session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) - assert session_id is not None - - # Test without Accept header - response = requests.get( - mcp_url, - headers={ - MCP_SESSION_ID_HEADER: session_id, - }, - stream=True, - ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text - - # Test with wrong Accept header - response = requests.get( - mcp_url, - headers={ - "Accept": "application/json", - MCP_SESSION_ID_HEADER: session_id, - }, - ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text + mcp_url = f"{basic_server_url}/mcp" + init_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 + + # Get the session ID + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + assert session_id is not None + + # Test without Accept header + response = requests.get( + mcp_url, + headers={ + MCP_SESSION_ID_HEADER: session_id, + }, + stream=True, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text + + # Test with wrong Accept header + response = requests.get( + mcp_url, + headers={ + "Accept": "application/json", + MCP_SESSION_ID_HEADER: session_id, + }, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text # Client-specific fixtures @@ -1226,4 +1222,4 @@ async def sampling_callback( assert ( captured_message_params.messages[0].content.text == "Server needs client sampling" - ) + ) \ No newline at end of file From 04f637c2d7b79b5cf6022735969ed73653f2fbe7 Mon Sep 17 00:00:00 2001 From: 633WHU Date: Wed, 28 May 2025 11:19:13 +0800 Subject: [PATCH 11/26] Update test_streamable_http.py --- tests/shared/test_streamable_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 40ca3d953..f1c7ef809 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1222,4 +1222,4 @@ async def sampling_callback( assert ( captured_message_params.messages[0].content.text == "Server needs client sampling" - ) \ No newline at end of file + ) From 2377b70b371045ee57d42080bc55aaea9dcb19be Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 11:22:39 +0800 Subject: [PATCH 12/26] add unit test for Streamable HTTP Trailing Slash Compatibility --- tests/server/test_fastmcp_streamable_http_mount.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/server/test_fastmcp_streamable_http_mount.py b/tests/server/test_fastmcp_streamable_http_mount.py index e0b346003..7b89072dc 100644 --- a/tests/server/test_fastmcp_streamable_http_mount.py +++ b/tests/server/test_fastmcp_streamable_http_mount.py @@ -1,7 +1,7 @@ """Test FastMCP streamable_http_app mounts both /mcp and /mcp/ automatically.""" +import httpx import pytest -from starlette.testclient import TestClient from mcp.server.fastmcp import FastMCP @pytest.fixture @@ -11,11 +11,15 @@ def fastmcp_app(): return app def test_streamable_http_mount_dual_paths(fastmcp_app): - client = TestClient(fastmcp_app) + # Use httpx.ASGITransport to avoid DeprecationWarning + transport = httpx.ASGITransport(app=fastmcp_app) + client = httpx.Client(transport=transport, base_url="http://testserver") for path in ["/mcp", "/mcp/"]: # Should return 406 because Accept header is missing, but proves route exists - resp = client.post(path, json={"jsonrpc": "2.0", "method": "initialize", "id": 1}) + resp = client.post( + path, json={"jsonrpc": "2.0", "method": "initialize", "id": 1} + ) assert resp.status_code in (400, 406) # 406 Not Acceptable or 400 Bad Request # Optionally, test GET as well resp_get = client.get(path) - assert resp_get.status_code in (400, 406, 405) # 405 if GET not allowed + assert resp_get.status_code in (400, 406, 405) From a03c62ed16b2f4751c7c6dc387b858addb4fcbbd Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 11:27:42 +0800 Subject: [PATCH 13/26] add unit test for Streamable HTTP Trailing Slash Compatibility --- .../test_fastmcp_streamable_http_mount.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/server/test_fastmcp_streamable_http_mount.py b/tests/server/test_fastmcp_streamable_http_mount.py index 7b89072dc..4f7dfe9ce 100644 --- a/tests/server/test_fastmcp_streamable_http_mount.py +++ b/tests/server/test_fastmcp_streamable_http_mount.py @@ -11,15 +11,15 @@ def fastmcp_app(): return app def test_streamable_http_mount_dual_paths(fastmcp_app): - # Use httpx.ASGITransport to avoid DeprecationWarning - transport = httpx.ASGITransport(app=fastmcp_app) - client = httpx.Client(transport=transport, base_url="http://testserver") - for path in ["/mcp", "/mcp/"]: - # Should return 406 because Accept header is missing, but proves route exists - resp = client.post( - path, json={"jsonrpc": "2.0", "method": "initialize", "id": 1} - ) - assert resp.status_code in (400, 406) # 406 Not Acceptable or 400 Bad Request - # Optionally, test GET as well - resp_get = client.get(path) - assert resp_get.status_code in (400, 406, 405) + # Use httpx.AsyncClient with ASGITransport for async test + async def do_test(): + async with httpx.AsyncClient(app=fastmcp_app, base_url="http://testserver") as client: + for path in ["/mcp", "/mcp/"]: + resp = await client.post( + path, json={"jsonrpc": "2.0", "method": "initialize", "id": 1} + ) + assert resp.status_code in (400, 406) + resp_get = await client.get(path) + assert resp_get.status_code in (400, 406, 405) + import anyio + anyio.run(do_test) From 10ccbbfe91b2a30bfce2a739aae44c71a5e7c24c Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 11:48:37 +0800 Subject: [PATCH 14/26] add unit test for Streamable HTTP Trailing Slash Compatibility --- .../test_fastmcp_streamable_http_mount.py | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 tests/server/test_fastmcp_streamable_http_mount.py diff --git a/tests/server/test_fastmcp_streamable_http_mount.py b/tests/server/test_fastmcp_streamable_http_mount.py deleted file mode 100644 index 4f7dfe9ce..000000000 --- a/tests/server/test_fastmcp_streamable_http_mount.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Test FastMCP streamable_http_app mounts both /mcp and /mcp/ automatically.""" - -import httpx -import pytest -from mcp.server.fastmcp import FastMCP - -@pytest.fixture -def fastmcp_app(): - mcp = FastMCP(name="TestServer") - app = mcp.streamable_http_app() - return app - -def test_streamable_http_mount_dual_paths(fastmcp_app): - # Use httpx.AsyncClient with ASGITransport for async test - async def do_test(): - async with httpx.AsyncClient(app=fastmcp_app, base_url="http://testserver") as client: - for path in ["/mcp", "/mcp/"]: - resp = await client.post( - path, json={"jsonrpc": "2.0", "method": "initialize", "id": 1} - ) - assert resp.status_code in (400, 406) - resp_get = await client.get(path) - assert resp_get.status_code in (400, 406, 405) - import anyio - anyio.run(do_test) From e102e7db7238dc40445ec8f526ef7babf49b160a Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 16:41:57 +0800 Subject: [PATCH 15/26] add unit test for Streamable HTTP Trailing Slash Compatibility --- tests/server/fastmcp/test_server.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index b817761ea..685a5e58b 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -124,6 +124,22 @@ async def test_starlette_routes_with_mount_path(self): mount_routes[0].path == "/messages" ), "Mount route path should be /messages" + mcp = FastMCP() + app = mcp.streamable_http_app() + + # Find routes by type + streamable_routes = [r for r in app.routes if isinstance(r, Route)] + mount_routes = [r for r in app.routes if isinstance(r, Mount)] + + # Verify routes exist + assert len(streamable_routes) == 2, "Should have two streamable routes" + + # Verify path values + assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp" + assert streamable_routes[1].path == "/mcp/", "Streamable route path should be /mcp" + + + @pytest.mark.anyio async def test_non_ascii_description(self): """Test that FastMCP handles non-ASCII characters in descriptions correctly""" From 6cf74f1340b4bbe56fa3e0c375d8126556a1b7d3 Mon Sep 17 00:00:00 2001 From: chiliu Date: Thu, 29 May 2025 10:02:09 +0800 Subject: [PATCH 16/26] change Mount to Routers --- src/mcp/server/fastmcp/server.py | 36 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index a07b2c8e0..3d20ac602 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -770,7 +770,7 @@ 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 + from starlette.routing import Route # Create session manager on first call (lazy initialization) if self._session_manager is None: @@ -782,20 +782,17 @@ 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) + async def handle_streamable_http(request: Request) -> Response: + await self.session_manager.handle_request(request.scope, request.receive, request._send) + return Response() # Create routes - routes: list[Route | Mount] = [] + routes: list[Route] = [] middleware: list[Middleware] = [] required_scopes = [] - # Always mount both /mcp and /mcp/ for full compatibility, regardless of default - # Verify that _main_path has root format -> /mcp + # Always register both /mcp and /mcp/ for full compatibility _main_path = self.settings.streamable_http_path.removesuffix("/") - # Format _alt_path so it ends with '/' -> /mcp/ _alt_path = _main_path + "/" # Add auth endpoints if auth provider is configured @@ -824,25 +821,29 @@ async def handle_streamable_http( ) ) routes.extend([ - Mount( + Route( _main_path, - app=RequireAuthMiddleware(handle_streamable_http, required_scopes), + endpoint=RequireAuthMiddleware(handle_streamable_http, required_scopes), + methods=["GET", "POST", "OPTIONS"] ), - Mount( + Route( _alt_path, - app=RequireAuthMiddleware(handle_streamable_http, required_scopes), + endpoint=RequireAuthMiddleware(handle_streamable_http, required_scopes), + methods=["GET", "POST", "OPTIONS"] )] ) else: # Auth is disabled, no wrapper needed routes.extend([ - Mount( + Route( _main_path, - app=handle_streamable_http, + endpoint=handle_streamable_http, + methods=["GET", "POST", "OPTIONS"] ), - Mount( + Route( _alt_path, - app=handle_streamable_http, + endpoint=handle_streamable_http, + methods=["GET", "POST", "OPTIONS"] )] ) @@ -853,6 +854,7 @@ async def handle_streamable_http( routes=routes, middleware=middleware, lifespan=lambda app: self.session_manager.run(), + redirect_slashes=False, ) async def list_prompts(self) -> list[MCPPrompt]: From 2748a75a82a9c872c000856c423380ef754ba55e Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 19:25:32 -0700 Subject: [PATCH 17/26] fix tests --- src/mcp/server/fastmcp/server.py | 1 - tests/server/fastmcp/test_server.py | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 3d20ac602..f3cc13f82 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -854,7 +854,6 @@ async def handle_streamable_http(request: Request) -> Response: routes=routes, middleware=middleware, lifespan=lambda app: self.session_manager.run(), - redirect_slashes=False, ) async def list_prompts(self) -> list[MCPPrompt]: diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 685a5e58b..5b61d53ab 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -129,16 +129,13 @@ async def test_starlette_routes_with_mount_path(self): # Find routes by type streamable_routes = [r for r in app.routes if isinstance(r, Route)] - mount_routes = [r for r in app.routes if isinstance(r, Mount)] # Verify routes exist assert len(streamable_routes) == 2, "Should have two streamable routes" # Verify path values assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp" - assert streamable_routes[1].path == "/mcp/", "Streamable route path should be /mcp" - - + assert streamable_routes[1].path == "/mcp/", "Streamable route path should be /mcp/" @pytest.mark.anyio async def test_non_ascii_description(self): From b726c86e63b29c177ec9efa0b39eb681473a61b3 Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 19:31:41 -0700 Subject: [PATCH 18/26] redirect_slashes to False --- src/mcp/server/fastmcp/server.py | 62 +++++++++++++++++------------ tests/server/fastmcp/test_server.py | 8 +++- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index f3cc13f82..8f1cfada6 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -603,7 +603,7 @@ async def run_sse_async(self, mount_path: str | None = None) -> None: import uvicorn starlette_app = self.sse_app(mount_path) - + config = uvicorn.Config( starlette_app, host=self.settings.host, @@ -618,7 +618,7 @@ async def run_streamable_http_async(self) -> None: import uvicorn starlette_app = self.streamable_http_app() - + starlette_app.router.redirect_slashes = False config = uvicorn.Config( starlette_app, host=self.settings.host, @@ -783,7 +783,9 @@ def streamable_http_app(self) -> Starlette: # Create the ASGI handler async def handle_streamable_http(request: Request) -> Response: - await self.session_manager.handle_request(request.scope, request.receive, request._send) + await self.session_manager.handle_request( + request.scope, request.receive, request._send + ) return Response() # Create routes @@ -793,7 +795,7 @@ async def handle_streamable_http(request: Request) -> Response: # Always register both /mcp and /mcp/ for full compatibility _main_path = self.settings.streamable_http_path.removesuffix("/") - _alt_path = _main_path + "/" + _alt_path = _main_path + "/" # Add auth endpoints if auth provider is configured if self._auth_server_provider: @@ -820,31 +822,39 @@ async def handle_streamable_http(request: Request) -> Response: revocation_options=self.settings.auth.revocation_options, ) ) - routes.extend([ - Route( - _main_path, - endpoint=RequireAuthMiddleware(handle_streamable_http, required_scopes), - methods=["GET", "POST", "OPTIONS"] - ), - Route( - _alt_path, - endpoint=RequireAuthMiddleware(handle_streamable_http, required_scopes), - methods=["GET", "POST", "OPTIONS"] - )] + routes.extend( + [ + Route( + _main_path, + endpoint=RequireAuthMiddleware( + handle_streamable_http, required_scopes + ), + methods=["GET", "POST", "OPTIONS"], + ), + Route( + _alt_path, + endpoint=RequireAuthMiddleware( + handle_streamable_http, required_scopes + ), + methods=["GET", "POST", "OPTIONS"], + ), + ] ) else: # Auth is disabled, no wrapper needed - routes.extend([ - Route( - _main_path, - endpoint=handle_streamable_http, - methods=["GET", "POST", "OPTIONS"] - ), - Route( - _alt_path, - endpoint=handle_streamable_http, - methods=["GET", "POST", "OPTIONS"] - )] + routes.extend( + [ + Route( + _main_path, + endpoint=handle_streamable_http, + methods=["GET", "POST", "OPTIONS"], + ), + Route( + _alt_path, + endpoint=handle_streamable_http, + methods=["GET", "POST", "OPTIONS"], + ), + ] ) routes.extend(self._custom_starlette_routes) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 5b61d53ab..9f972a08f 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -134,8 +134,12 @@ async def test_starlette_routes_with_mount_path(self): assert len(streamable_routes) == 2, "Should have two streamable routes" # Verify path values - assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp" - assert streamable_routes[1].path == "/mcp/", "Streamable route path should be /mcp/" + assert ( + streamable_routes[0].path == "/mcp" + ), "Streamable route path should be /mcp" + assert ( + streamable_routes[1].path == "/mcp/" + ), "Streamable route path should be /mcp/" @pytest.mark.anyio async def test_non_ascii_description(self): From 3b03b83845eb8365b0f167184bda6577314ddb0f Mon Sep 17 00:00:00 2001 From: 633WHU Date: Thu, 29 May 2025 10:32:41 +0800 Subject: [PATCH 19/26] Update server.py --- src/mcp/server/fastmcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 8f1cfada6..daa07753e 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -603,7 +603,7 @@ async def run_sse_async(self, mount_path: str | None = None) -> None: import uvicorn starlette_app = self.sse_app(mount_path) - + config = uvicorn.Config( starlette_app, host=self.settings.host, From d2ee4df69a303763e78f95e19e98772e7d5d75f2 Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 19:35:14 -0700 Subject: [PATCH 20/26] fix typo --- src/mcp/server/fastmcp/server.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 8f1cfada6..74af348ea 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -782,11 +782,10 @@ def streamable_http_app(self) -> Starlette: ) # Create the ASGI handler - async def handle_streamable_http(request: Request) -> Response: - await self.session_manager.handle_request( - request.scope, request.receive, request._send - ) - return Response() + async def handle_streamable_http( + scope: Scope, receive: Receive, send: Send + ) -> None: + await self.session_manager.handle_request(scope, receive, send) # Create routes routes: list[Route] = [] From 86da08d3c9206902f93d0e241e9734ecbb173c69 Mon Sep 17 00:00:00 2001 From: chiliu Date: Wed, 28 May 2025 19:51:44 -0700 Subject: [PATCH 21/26] fix typo --- src/mcp/server/fastmcp/server.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 44848cdf9..daa07753e 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -782,10 +782,11 @@ 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) + async def handle_streamable_http(request: Request) -> Response: + await self.session_manager.handle_request( + request.scope, request.receive, request._send + ) + return Response() # Create routes routes: list[Route] = [] From 3d86e61c1a3ed000175ab2d49200462387fa4233 Mon Sep 17 00:00:00 2001 From: chiliu Date: Thu, 29 May 2025 02:11:24 -0700 Subject: [PATCH 22/26] fix --- src/mcp/server/fastmcp/server.py | 58 ++++++++++---------------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index daa07753e..d970c4db8 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -618,7 +618,7 @@ async def run_streamable_http_async(self) -> None: import uvicorn starlette_app = self.streamable_http_app() - starlette_app.router.redirect_slashes = False + config = uvicorn.Config( starlette_app, host=self.settings.host, @@ -782,21 +782,16 @@ def streamable_http_app(self) -> Starlette: ) # Create the ASGI handler - async def handle_streamable_http(request: Request) -> Response: - await self.session_manager.handle_request( - request.scope, request.receive, request._send - ) - return Response() + async def handle_streamable_http( + scope: Scope, receive: Receive, send: Send + ) -> None: + await self.session_manager.handle_request(scope, receive, send) # Create routes - routes: list[Route] = [] + routes: list[Route | Mount] = [] middleware: list[Middleware] = [] required_scopes = [] - # Always register both /mcp and /mcp/ for full compatibility - _main_path = self.settings.streamable_http_path.removesuffix("/") - _alt_path = _main_path + "/" - # Add auth endpoints if auth provider is configured if self._auth_server_provider: assert self.settings.auth @@ -822,39 +817,19 @@ async def handle_streamable_http(request: Request) -> Response: revocation_options=self.settings.auth.revocation_options, ) ) - routes.extend( - [ - Route( - _main_path, - endpoint=RequireAuthMiddleware( - handle_streamable_http, required_scopes - ), - methods=["GET", "POST", "OPTIONS"], - ), - Route( - _alt_path, - endpoint=RequireAuthMiddleware( - handle_streamable_http, required_scopes - ), - methods=["GET", "POST", "OPTIONS"], - ), - ] + routes.append( + Mount( + self.settings.streamable_http_path, + app=RequireAuthMiddleware(handle_streamable_http, required_scopes), + ) ) else: # Auth is disabled, no wrapper needed - routes.extend( - [ - Route( - _main_path, - endpoint=handle_streamable_http, - methods=["GET", "POST", "OPTIONS"], - ), - Route( - _alt_path, - endpoint=handle_streamable_http, - methods=["GET", "POST", "OPTIONS"], - ), - ] + routes.append( + Mount( + self.settings.streamable_http_path, + app=handle_streamable_http, + ) ) routes.extend(self._custom_starlette_routes) @@ -864,6 +839,7 @@ async def handle_streamable_http(request: Request) -> Response: routes=routes, middleware=middleware, lifespan=lambda app: self.session_manager.run(), + redirect_slashes=False, ) async def list_prompts(self) -> list[MCPPrompt]: From cc9f3d98323d2e5389b4102914922508457f3d60 Mon Sep 17 00:00:00 2001 From: 633WHU Date: Thu, 29 May 2025 17:12:14 +0800 Subject: [PATCH 23/26] Update server.py --- src/mcp/server/fastmcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index d970c4db8..1dbad5a02 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -770,7 +770,7 @@ 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 Route + from starlette.routing import Mount # Create session manager on first call (lazy initialization) if self._session_manager is None: From cce6d2f74cbf7f083934d3a95d17c219cf765b4f Mon Sep 17 00:00:00 2001 From: 633WHU Date: Thu, 29 May 2025 17:12:53 +0800 Subject: [PATCH 24/26] Update test_server.py --- tests/server/fastmcp/test_server.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 9f972a08f..b817761ea 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -124,23 +124,6 @@ async def test_starlette_routes_with_mount_path(self): mount_routes[0].path == "/messages" ), "Mount route path should be /messages" - mcp = FastMCP() - app = mcp.streamable_http_app() - - # Find routes by type - streamable_routes = [r for r in app.routes if isinstance(r, Route)] - - # Verify routes exist - assert len(streamable_routes) == 2, "Should have two streamable routes" - - # Verify path values - assert ( - streamable_routes[0].path == "/mcp" - ), "Streamable route path should be /mcp" - assert ( - streamable_routes[1].path == "/mcp/" - ), "Streamable route path should be /mcp/" - @pytest.mark.anyio async def test_non_ascii_description(self): """Test that FastMCP handles non-ASCII characters in descriptions correctly""" From 1d9632b54b77b708c71887e3873942b061bc71fc Mon Sep 17 00:00:00 2001 From: Vincenzo Maria Calandra <65887821+vectorstain@users.noreply.github.com> Date: Thu, 29 May 2025 16:20:47 +0200 Subject: [PATCH 25/26] Fix 307 temporary redirect refactor (#4) * Refactor FastMCP routing to use Router and streamline request handling * Add async support to token validation test and enhance metadata snapshot assertions * Fix tests * Fix tests --------- Co-authored-by: Vincenzo Maria Calandra Co-authored-by: 633WHU --- src/mcp/server/fastmcp/server.py | 31 +++++++++++++++++++++-------- tests/server/fastmcp/test_server.py | 14 +++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 1dbad5a02..8520beade 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -770,7 +770,7 @@ 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 + from starlette.routing import Mount, Router # Create session manager on first call (lazy initialization) if self._session_manager is None: @@ -787,12 +787,24 @@ async def handle_streamable_http( ) -> None: await self.session_manager.handle_request(scope, receive, send) - # Create routes - routes: list[Route | Mount] = [] + async def streamable_http_endpoint(request: Request): + return await handle_streamable_http(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage] + + # Normalize the main path (no trailing slash) + _main_path = self.settings.streamable_http_path.removesuffix("/") + + streamable_router = Router( + routes=[ + Route("/", endpoint=streamable_http_endpoint, methods=["GET", "POST"]), + ], + redirect_slashes=False, + ) + + routes: list[Route | Mount ] = [] middleware: list[Middleware] = [] required_scopes = [] - # Add auth endpoints if auth provider is configured + # Auth endpoints if auth provider is configured if self._auth_server_provider: assert self.settings.auth from mcp.server.auth.routes import create_auth_routes @@ -817,18 +829,21 @@ async def handle_streamable_http( revocation_options=self.settings.auth.revocation_options, ) ) + routes.append( Mount( - self.settings.streamable_http_path, - app=RequireAuthMiddleware(handle_streamable_http, required_scopes), + _main_path, + app=RequireAuthMiddleware( + streamable_router, required_scopes + ), ) ) else: # Auth is disabled, no wrapper needed routes.append( Mount( - self.settings.streamable_http_path, - app=handle_streamable_http, + _main_path, + app=streamable_router, ) ) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index b817761ea..a975974eb 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -124,6 +124,20 @@ async def test_starlette_routes_with_mount_path(self): mount_routes[0].path == "/messages" ), "Mount route path should be /messages" + mcp = FastMCP() + app = mcp.streamable_http_app() + + # Find routes by type + streamable_routes = [r for r in app.routes if isinstance(r, Mount)] + + # Verify routes exist + assert len(streamable_routes) == 1, "Should have two streamable routes" + + # Verify path values + assert ( + streamable_routes[0].path == "/mcp" + ), "Streamable route path should be /mcp" + @pytest.mark.anyio async def test_non_ascii_description(self): """Test that FastMCP handles non-ASCII characters in descriptions correctly""" From 2aa0428d92c6280afe14d6e4c1ff5ba2b5d92fd3 Mon Sep 17 00:00:00 2001 From: 633WHU Date: Thu, 29 May 2025 22:22:50 +0800 Subject: [PATCH 26/26] Update server.py --- src/mcp/server/fastmcp/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 8520beade..55a15c333 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -854,7 +854,6 @@ async def streamable_http_endpoint(request: Request): routes=routes, middleware=middleware, lifespan=lambda app: self.session_manager.run(), - redirect_slashes=False, ) async def list_prompts(self) -> list[MCPPrompt]: