diff --git a/README.md b/README.md index e268a7f401..ae3e73f062 100644 --- a/README.md +++ b/README.md @@ -1078,7 +1078,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv - `debug` - Debug mode flag - `log_level` - Current logging level - `host` and `port` - Server network configuration - - `mount_path`, `sse_path`, `streamable_http_path` - Transport paths + - `sse_path`, `streamable_http_path` - Transport paths - `stateless_http` - Whether the server operates in stateless mode - And other configuration options @@ -1614,7 +1614,7 @@ app = Starlette( app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) ``` -When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: +You can also mount multiple MCP servers at different sub-paths. The SSE transport automatically detects the mount path via ASGI's `root_path` mechanism, so message endpoints are correctly routed: ```python from starlette.applications import Starlette @@ -1624,31 +1624,18 @@ from mcp.server.fastmcp import FastMCP # Create multiple MCP servers github_mcp = FastMCP("GitHub API") browser_mcp = FastMCP("Browser") -curl_mcp = FastMCP("Curl") search_mcp = FastMCP("Search") -# Method 1: Configure mount paths via settings (recommended for persistent configuration) -github_mcp.settings.mount_path = "/github" -browser_mcp.settings.mount_path = "/browser" - -# 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 +# Mount each server at its own sub-path +# The SSE transport automatically uses ASGI's root_path to construct +# the correct message endpoint (e.g., /github/messages/, /browser/messages/) app = Starlette( routes=[ - # 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")), + Mount("/search", app=search_mcp.sse_app()), ] ) - -# 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/docs/migration.md b/docs/migration.md index 8523309a31..bb0defc011 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -116,6 +116,12 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) ``` +### `mount_path` parameter removed from FastMCP + +The `mount_path` parameter has been removed from `FastMCP.__init__()`, `FastMCP.run()`, `FastMCP.run_sse_async()`, and `FastMCP.sse_app()`. It was also removed from the `Settings` class. + +This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path. + ### Resource URI type changed from `AnyUrl` to `str` The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 7a612793a4..0d18df1131 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -76,7 +76,6 @@ class Settings(BaseSettings, Generic[LifespanResultT]): # HTTP settings host: str port: int - mount_path: str sse_path: str message_path: str streamable_http_path: str @@ -138,7 +137,6 @@ def __init__( # noqa: PLR0913 log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", host: str = "127.0.0.1", port: int = 8000, - mount_path: str = "/", sse_path: str = "/sse", message_path: str = "/messages/", streamable_http_path: str = "/mcp", @@ -164,7 +162,6 @@ def __init__( # noqa: PLR0913 log_level=log_level, host=host, port=port, - mount_path=mount_path, sse_path=sse_path, message_path=message_path, streamable_http_path=streamable_http_path, @@ -269,13 +266,11 @@ def session_manager(self) -> StreamableHTTPSessionManager: def run( self, transport: Literal["stdio", "sse", "streamable-http"] = "stdio", - mount_path: str | None = None, ) -> None: """Run the FastMCP server. Note this is a synchronous function. Args: transport: Transport protocol to use ("stdio", "sse", or "streamable-http") - mount_path: Optional mount path for SSE transport """ TRANSPORTS = Literal["stdio", "sse", "streamable-http"] if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover @@ -285,7 +280,7 @@ def run( case "stdio": anyio.run(self.run_stdio_async) case "sse": # pragma: no cover - anyio.run(lambda: self.run_sse_async(mount_path)) + anyio.run(self.run_sse_async) case "streamable-http": # pragma: no cover anyio.run(self.run_streamable_http_async) @@ -749,11 +744,11 @@ async def run_stdio_async(self) -> None: self._mcp_server.create_initialization_options(), ) - async def run_sse_async(self, mount_path: str | None = None) -> None: # pragma: no cover + async def run_sse_async(self) -> None: # pragma: no cover """Run the server using SSE transport.""" import uvicorn - starlette_app = self.sse_app(mount_path) + starlette_app = self.sse_app() config = uvicorn.Config( starlette_app, @@ -779,58 +774,16 @@ async def run_streamable_http_async(self) -> None: # pragma: no cover 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, mount_path: str | None = None) -> Starlette: + def sse_app(self) -> Starlette: """Return an instance of the SSE server app.""" - # Update mount_path in settings if provided - if mount_path is not None: - self.settings.mount_path = mount_path - - # Create normalized endpoint considering the mount path - normalized_message_endpoint = self._normalize_path(self.settings.mount_path, self.settings.message_path) - - # Set up auth context and dependencies - - sse = SseServerTransport( - normalized_message_endpoint, - security_settings=self.settings.transport_security, - ) + sse = SseServerTransport(self.settings.message_path, security_settings=self.settings.transport_security) async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover # Add client ID from auth context into request context if available async with sse.connect_sse(scope, receive, send) as streams: - await self._mcp_server.run( - streams[0], - streams[1], - self._mcp_server.create_initialization_options(), - ) + await self._mcp_server.run(streams[0], streams[1], self._mcp_server.create_initialization_options()) return Response() # Create routes diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 68adb7ee40..87637fcb8a 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -49,85 +49,23 @@ async def test_create_server(self): assert mcp.icons[0].src == "https://example.com/icon.png" @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 in settings - 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/") - - # 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/") + async def test_sse_app_returns_starlette_app(self): + """Test that sse_app returns a Starlette application with correct routes.""" + from starlette.applications import Starlette - @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" + mcp = FastMCP("test", host="0.0.0.0") # Use 0.0.0.0 to avoid auto DNS protection 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)] + assert isinstance(app, Starlette) # 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" - - # 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" + assert sse_routes[0].path == "/sse" + assert mount_routes[0].path == "/messages" @pytest.mark.anyio async def test_non_ascii_description(self):