From 9325779b4eca822d036c190b0aba2652a060c78e Mon Sep 17 00:00:00 2001 From: tim-watcha Date: Sat, 19 Apr 2025 12:31:12 +0900 Subject: [PATCH 1/7] Add mount_path support to FastMCP for proper SSE endpoint handling This commit adds a mount_path attribute to FastMCP settings to support properly constructing SSE endpoints when servers are mounted under sub-paths. The _normalize_path helper method combines mount paths with endpoints to ensure correct session_uri generation. This fixes issues when running multiple FastMCP servers under different path prefixes in a single Starlette application. --- src/mcp/server/fastmcp/server.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index f3bb2586..f48bdea9 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -72,6 +72,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]): # HTTP settings host: str = "0.0.0.0" port: int = 8000 + mount_path: str = "/" # Mount path (e.g. "/github", defaults to root path) sse_path: str = "/sse" message_path: str = "/messages/" @@ -477,9 +478,37 @@ async def run_sse_async(self) -> None: server = uvicorn.Server(config) await server.serve() + def _normalize_path(self, mount_path: str, endpoint: str) -> str: + """ + Combine mount path and endpoint to return a normalized path. + + Args: + mount_path: The mount path (e.g. "/github" or "/") + endpoint: The endpoint path (e.g. "/messages/") + + Returns: + Normalized path (e.g. "/github/messages/") + """ + # Special case: root path + if mount_path == "/": + return endpoint + + # Remove trailing slash from mount path + if mount_path.endswith("/"): + mount_path = mount_path[:-1] + + # Ensure endpoint starts with slash + if not endpoint.startswith("/"): + endpoint = "/" + endpoint + + # Combine paths + return mount_path + endpoint + def sse_app(self) -> Starlette: """Return an instance of the SSE server app.""" - sse = SseServerTransport(self.settings.message_path) + # Create normalized endpoint considering the mount path + normalized_endpoint = self._normalize_path(self.settings.mount_path, self.settings.message_path) + sse = SseServerTransport(normalized_endpoint) async def handle_sse(request: Request) -> None: async with sse.connect_sse( From c4c05e1bd964fe1bbe91882ed045231b28ba4b2a Mon Sep 17 00:00:00 2001 From: tim-watcha Date: Sat, 19 Apr 2025 12:41:52 +0900 Subject: [PATCH 2/7] Add tests for mount_path and path normalization in FastMCP Tests to verify proper handling of mount paths when creating SSE applications. Ensures path normalization works correctly for different path combinations. --- tests/server/fastmcp/test_server.py | 67 ++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index e76e59c5..e1b007ad 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -1,9 +1,11 @@ import base64 from pathlib import Path from typing import TYPE_CHECKING +from unittest.mock import patch import pytest from pydantic import AnyUrl +from starlette.routing import Mount, Route from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage @@ -31,6 +33,69 @@ async def test_create_server(self): assert mcp.name == "FastMCP" assert mcp.instructions == "Server instructions" + @pytest.mark.anyio + async def test_normalize_path(self): + """Test path normalization for mount paths.""" + mcp = FastMCP() + + # Test root path + assert mcp._normalize_path("/", "/messages/") == "/messages/" + + # Test path with trailing slash + assert mcp._normalize_path("/github/", "/messages/") == "/github/messages/" + + # Test path without trailing slash + assert mcp._normalize_path("/github", "/messages/") == "/github/messages/" + + # Test endpoint without leading slash + assert mcp._normalize_path("/github", "messages/") == "/github/messages/" + + # Test both with trailing/leading slashes + assert mcp._normalize_path("/api/", "/v1/") == "/api/v1/" + + @pytest.mark.anyio + async def test_sse_app_with_mount_path(self): + """Test SSE app creation with different mount paths.""" + # Test with default mount path + mcp = FastMCP() + with patch.object( + mcp, "_normalize_path", return_value="/messages/" + ) as mock_normalize: + mcp.sse_app() + # Verify _normalize_path was called with correct args + mock_normalize.assert_called_once_with("/", "/messages/") + + # Test with custom mount path + mcp = FastMCP() + mcp.settings.mount_path = "/custom" + with patch.object( + mcp, "_normalize_path", return_value="/custom/messages/" + ) as mock_normalize: + mcp.sse_app() + # Verify _normalize_path was called with correct args + mock_normalize.assert_called_once_with("/custom", "/messages/") + + @pytest.mark.anyio + async def test_starlette_routes_with_mount_path(self): + """Test that Starlette routes are correctly configured with mount path.""" + mcp = FastMCP() + mcp.settings.mount_path = "/api" + app = mcp.sse_app() + + # Find routes by type + sse_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(sse_routes) == 1, "Should have one SSE route" + assert len(mount_routes) == 1, "Should have one mount route" + + # Verify path values + assert sse_routes[0].path == "/sse", "SSE route path should be /sse" + assert ( + mount_routes[0].path == "/messages" + ), "Mount route path should be /messages" + @pytest.mark.anyio async def test_non_ascii_description(self): """Test that FastMCP handles non-ASCII characters in descriptions correctly""" @@ -518,8 +583,6 @@ async def async_tool(x: int, ctx: Context) -> str: @pytest.mark.anyio async def test_context_logging(self): - from unittest.mock import patch - import mcp.server.session """Test that context logging methods work.""" From d5bd5fdb9925929b7a1694807f7ed78f71910627 Mon Sep 17 00:00:00 2001 From: tim-watcha Date: Sat, 19 Apr 2025 12:44:45 +0900 Subject: [PATCH 3/7] Add multi-server mounting example to README This commit adds documentation for mounting multiple FastMCP servers under different paths, showing how to configure mount_path for each server instance. --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 0ca039ae..716a1b07 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,33 @@ app = Starlette( app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) ``` +When mounting multiple MCP servers under different paths, you need to configure the mount path for each server: + +```python +from starlette.applications import Starlette +from starlette.routing import Mount +from mcp.server.fastmcp import FastMCP + +# Create multiple MCP servers +github_mcp = FastMCP("GitHub API") +browser_mcp = FastMCP("Browser") +curl_mcp = FastMCP("Curl") + +# Configure mount paths for each server +github_mcp.settings.mount_path = "/github" +browser_mcp.settings.mount_path = "/browser" +curl_mcp.settings.mount_path = "/curl" + +# Create Starlette app with multiple mounted servers +app = Starlette( + routes=[ + Mount("/github", app=github_mcp.sse_app()), + Mount("/browser", app=browser_mcp.sse_app()), + Mount("/curl", app=curl_mcp.sse_app()), + ] +) +``` + For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). ## Examples From dfdf077aa985d7f20ecb0a51bdcf96c2765f4dae Mon Sep 17 00:00:00 2001 From: tim-watcha Date: Sat, 19 Apr 2025 13:01:47 +0900 Subject: [PATCH 4/7] Fix line length issue in server.py Format code to comply with line length limit (88 characters). --- src/mcp/server/fastmcp/server.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index f48bdea9..4eb5af60 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -467,6 +467,7 @@ async def run_stdio_async(self) -> None: async def run_sse_async(self) -> None: """Run the server using SSE transport.""" import uvicorn + starlette_app = self.sse_app() config = uvicorn.Config( @@ -481,33 +482,35 @@ async def run_sse_async(self) -> None: def _normalize_path(self, mount_path: str, endpoint: str) -> str: """ Combine mount path and endpoint to return a normalized path. - + Args: mount_path: The mount path (e.g. "/github" or "/") endpoint: The endpoint path (e.g. "/messages/") - + Returns: Normalized path (e.g. "/github/messages/") """ # Special case: root path if mount_path == "/": return endpoint - + # Remove trailing slash from mount path if mount_path.endswith("/"): mount_path = mount_path[:-1] - + # Ensure endpoint starts with slash if not endpoint.startswith("/"): endpoint = "/" + endpoint - + # Combine paths return mount_path + endpoint - + def sse_app(self) -> Starlette: """Return an instance of the SSE server app.""" # Create normalized endpoint considering the mount path - normalized_endpoint = self._normalize_path(self.settings.mount_path, self.settings.message_path) + normalized_endpoint = self._normalize_path( + self.settings.mount_path, self.settings.message_path + ) sse = SseServerTransport(normalized_endpoint) async def handle_sse(request: Request) -> None: From 1bd89ff3497632943de84d0ac1e2455e4d19a037 Mon Sep 17 00:00:00 2001 From: tim-watcha Date: Mon, 28 Apr 2025 09:32:09 +0900 Subject: [PATCH 5/7] Add mount_path parameter to sse_app method - Add mount_path parameter to sse_app method for direct path configuration - Update run_sse_async and run methods to support mount_path parameter - Update README.md with examples for different mounting methods - Add tests for new parameter functionality --- README.md | 19 ++++++++++++++---- src/mcp/server/fastmcp/server.py | 16 ++++++++++----- tests/server/fastmcp/test_server.py | 30 ++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 716a1b07..a57f7ff5 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,7 @@ app = Starlette( app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) ``` -When mounting multiple MCP servers under different paths, you need to configure the mount path for each server: +When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: ```python from starlette.applications import Starlette @@ -394,20 +394,31 @@ from mcp.server.fastmcp import FastMCP github_mcp = FastMCP("GitHub API") browser_mcp = FastMCP("Browser") curl_mcp = FastMCP("Curl") +search_mcp = FastMCP("Search") -# Configure mount paths for each server +# Method 1: Configure mount paths via settings (recommended for persistent configuration) github_mcp.settings.mount_path = "/github" browser_mcp.settings.mount_path = "/browser" -curl_mcp.settings.mount_path = "/curl" + +# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting) +# This approach doesn't modify the server's settings permanently # Create Starlette app with multiple mounted servers app = Starlette( routes=[ + # Using settings-based configuration Mount("/github", app=github_mcp.sse_app()), Mount("/browser", app=browser_mcp.sse_app()), - Mount("/curl", app=curl_mcp.sse_app()), + + # Using direct mount path parameter + Mount("/curl", app=curl_mcp.sse_app("/curl")), + Mount("/search", app=search_mcp.sse_app("/search")), ] ) + +# Method 3: For direct execution, you can also pass the mount path to run() +if __name__ == "__main__": + search_mcp.run(transport="sse", mount_path="/search") ``` For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index bc9934b3..35e859d4 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -144,11 +144,14 @@ def name(self) -> str: def instructions(self) -> str | None: return self._mcp_server.instructions - def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None: + def run( + self, transport: Literal["stdio", "sse"] = "stdio", mount_path: str = "" + ) -> None: """Run the FastMCP server. Note this is a synchronous function. Args: transport: Transport protocol to use ("stdio" or "sse") + mount_path: Optional mount path for SSE transport """ TRANSPORTS = Literal["stdio", "sse"] if transport not in TRANSPORTS.__args__: # type: ignore @@ -157,7 +160,7 @@ def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None: if transport == "stdio": anyio.run(self.run_stdio_async) else: # transport == "sse" - anyio.run(self.run_sse_async) + anyio.run(lambda: self.run_sse_async(mount_path)) def _setup_handlers(self) -> None: """Set up core MCP protocol handlers.""" @@ -463,11 +466,11 @@ async def run_stdio_async(self) -> None: self._mcp_server.create_initialization_options(), ) - async def run_sse_async(self) -> None: + async def run_sse_async(self, mount_path: str = "") -> None: """Run the server using SSE transport.""" import uvicorn - starlette_app = self.sse_app() + starlette_app = self.sse_app(mount_path) config = uvicorn.Config( starlette_app, @@ -504,8 +507,11 @@ def _normalize_path(self, mount_path: str, endpoint: str) -> str: # Combine paths return mount_path + endpoint - def sse_app(self) -> Starlette: + def sse_app(self, mount_path: str = "") -> Starlette: """Return an instance of the SSE server app.""" + # Update mount_path in settings if provided + if mount_path: + self.settings.mount_path = mount_path # Create normalized endpoint considering the mount path normalized_endpoint = self._normalize_path( self.settings.mount_path, self.settings.message_path diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index e1b007ad..6e54306f 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -65,7 +65,7 @@ async def test_sse_app_with_mount_path(self): # Verify _normalize_path was called with correct args mock_normalize.assert_called_once_with("/", "/messages/") - # Test with custom mount path + # Test with custom mount path in settings mcp = FastMCP() mcp.settings.mount_path = "/custom" with patch.object( @@ -74,10 +74,20 @@ async def test_sse_app_with_mount_path(self): mcp.sse_app() # Verify _normalize_path was called with correct args mock_normalize.assert_called_once_with("/custom", "/messages/") + + # Test with mount_path parameter + mcp = FastMCP() + with patch.object( + mcp, "_normalize_path", return_value="/param/messages/" + ) as mock_normalize: + mcp.sse_app(mount_path="/param") + # Verify _normalize_path was called with correct args + mock_normalize.assert_called_once_with("/param", "/messages/") @pytest.mark.anyio async def test_starlette_routes_with_mount_path(self): """Test that Starlette routes are correctly configured with mount path.""" + # Test with mount path in settings mcp = FastMCP() mcp.settings.mount_path = "/api" app = mcp.sse_app() @@ -95,6 +105,24 @@ async def test_starlette_routes_with_mount_path(self): assert ( mount_routes[0].path == "/messages" ), "Mount route path should be /messages" + + # Test with mount path as parameter + mcp = FastMCP() + app = mcp.sse_app(mount_path="/param") + + # Find routes by type + sse_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(sse_routes) == 1, "Should have one SSE route" + assert len(mount_routes) == 1, "Should have one mount route" + + # Verify path values + assert sse_routes[0].path == "/sse", "SSE route path should be /sse" + assert ( + mount_routes[0].path == "/messages" + ), "Mount route path should be /messages" @pytest.mark.anyio async def test_non_ascii_description(self): From 30b38f96bd2c506d2ee82a8718de76af3ca3e751 Mon Sep 17 00:00:00 2001 From: tim-watcha Date: Mon, 28 Apr 2025 09:39:12 +0900 Subject: [PATCH 6/7] Apply ruff formatting to test_server.py --- tests/server/fastmcp/test_server.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 6e54306f..39f9e78c 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -74,7 +74,7 @@ async def test_sse_app_with_mount_path(self): mcp.sse_app() # Verify _normalize_path was called with correct args mock_normalize.assert_called_once_with("/custom", "/messages/") - + # Test with mount_path parameter mcp = FastMCP() with patch.object( @@ -105,19 +105,19 @@ async def test_starlette_routes_with_mount_path(self): assert ( mount_routes[0].path == "/messages" ), "Mount route path should be /messages" - + # Test with mount path as parameter mcp = FastMCP() app = mcp.sse_app(mount_path="/param") - + # Find routes by type sse_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(sse_routes) == 1, "Should have one SSE route" assert len(mount_routes) == 1, "Should have one mount route" - + # Verify path values assert sse_routes[0].path == "/sse", "SSE route path should be /sse" assert ( From e248cb3c9922b2d9db3771dd7da16b7c1cb0506a Mon Sep 17 00:00:00 2001 From: tim-watcha Date: Mon, 28 Apr 2025 09:42:28 +0900 Subject: [PATCH 7/7] Fix README.md code example formatting for CI tests --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index a57f7ff5..be248b4b 100644 --- a/README.md +++ b/README.md @@ -409,7 +409,6 @@ app = Starlette( # Using settings-based configuration Mount("/github", app=github_mcp.sse_app()), Mount("/browser", app=browser_mcp.sse_app()), - # Using direct mount path parameter Mount("/curl", app=curl_mcp.sse_app("/curl")), Mount("/search", app=search_mcp.sse_app("/search")),