Add mount_path support for proper SSE endpoint routing with multiple FastMCP servers (#540)

Co-authored-by: ihrpr <inna.hrpr@gmail.com>
This commit is contained in:
tim-watcha 2025-05-07 19:14:25 +09:00 • committed by GitHub
commit e0d443c95e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 178 additions and 8 deletions

View file

@ -410,6 +410,43 @@ app = Starlette(
app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) 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:
```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")
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
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")),
]
)
# 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). For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes).
#### Message Dispatch Options #### Message Dispatch Options

View file

@ -88,6 +88,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
# HTTP settings # HTTP settings
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 8000 port: int = 8000
mount_path: str = "/" # Mount path (e.g. "/github", defaults to root path)
sse_path: str = "/sse" sse_path: str = "/sse"
message_path: str = "/messages/" message_path: str = "/messages/"
@ -184,11 +185,16 @@ class FastMCP:
def instructions(self) -> str | None: def instructions(self) -> str | None:
return self._mcp_server.instructions 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 = None,
) -> None:
"""Run the FastMCP server. Note this is a synchronous function. """Run the FastMCP server. Note this is a synchronous function.
Args: Args:
transport: Transport protocol to use ("stdio" or "sse") transport: Transport protocol to use ("stdio" or "sse")
mount_path: Optional mount path for SSE transport
""" """
TRANSPORTS = Literal["stdio", "sse"] TRANSPORTS = Literal["stdio", "sse"]
if transport not in TRANSPORTS.__args__: # type: ignore if transport not in TRANSPORTS.__args__: # type: ignore
@ -197,7 +203,7 @@ class FastMCP:
if transport == "stdio": if transport == "stdio":
anyio.run(self.run_stdio_async) anyio.run(self.run_stdio_async)
else: # transport == "sse" else: # transport == "sse"
anyio.run(self.run_sse_async) anyio.run(lambda: self.run_sse_async(mount_path))
def _setup_handlers(self) -> None: def _setup_handlers(self) -> None:
"""Set up core MCP protocol handlers.""" """Set up core MCP protocol handlers."""
@ -558,11 +564,11 @@ class FastMCP:
self._mcp_server.create_initialization_options(), self._mcp_server.create_initialization_options(),
) )
async def run_sse_async(self) -> None: async def run_sse_async(self, mount_path: str | None = None) -> None:
"""Run the server using SSE transport.""" """Run the server using SSE transport."""
import uvicorn import uvicorn
starlette_app = self.sse_app() starlette_app = self.sse_app(mount_path)
config = uvicorn.Config( config = uvicorn.Config(
starlette_app, starlette_app,
@ -573,7 +579,33 @@ class FastMCP:
server = uvicorn.Server(config) server = uvicorn.Server(config)
await server.serve() await server.serve()
def sse_app(self) -> Starlette: 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:
"""Return an instance of the SSE server app.""" """Return an instance of the SSE server app."""
message_dispatch = self.settings.message_dispatch message_dispatch = self.settings.message_dispatch
if message_dispatch is None: if message_dispatch is None:
@ -585,10 +617,20 @@ class FastMCP:
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
# 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 # Set up auth context and dependencies
sse = SseServerTransport( sse = SseServerTransport(
self.settings.message_path, message_dispatch=message_dispatch normalized_message_endpoint,
message_dispatch=message_dispatch
) )
async def handle_sse(scope: Scope, receive: Receive, send: Send): async def handle_sse(scope: Scope, receive: Receive, send: Send):

View file

@ -1,9 +1,11 @@
import base64 import base64
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest import pytest
from pydantic import AnyUrl from pydantic import AnyUrl
from starlette.routing import Mount, Route
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
@ -31,6 +33,97 @@ class TestServer:
assert mcp.name == "FastMCP" assert mcp.name == "FastMCP"
assert mcp.instructions == "Server instructions" 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 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/")
@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()
# 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"
# 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 @pytest.mark.anyio
async def test_non_ascii_description(self): async def test_non_ascii_description(self):
"""Test that FastMCP handles non-ASCII characters in descriptions correctly""" """Test that FastMCP handles non-ASCII characters in descriptions correctly"""
@ -518,8 +611,6 @@ class TestContextInjection:
@pytest.mark.anyio @pytest.mark.anyio
async def test_context_logging(self): async def test_context_logging(self):
from unittest.mock import patch
import mcp.server.session import mcp.server.session
"""Test that context logging methods work.""" """Test that context logging methods work."""