Skip to content

Commit e0d443c

Browse files
tim-watchaihrpr
andauthored
Add mount_path support for proper SSE endpoint routing with multiple FastMCP servers (#540)
Co-authored-by: ihrpr <inna.hrpr@gmail.com>
1 parent 3b1b213 commit e0d443c

File tree

3 files changed

+178
-8
lines changed

3 files changed

+178
-8
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,43 @@ app = Starlette(
410410
app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app()))
411411
```
412412

413+
When mounting multiple MCP servers under different paths, you can configure the mount path in several ways:
414+
415+
```python
416+
from starlette.applications import Starlette
417+
from starlette.routing import Mount
418+
from mcp.server.fastmcp import FastMCP
419+
420+
# Create multiple MCP servers
421+
github_mcp = FastMCP("GitHub API")
422+
browser_mcp = FastMCP("Browser")
423+
curl_mcp = FastMCP("Curl")
424+
search_mcp = FastMCP("Search")
425+
426+
# Method 1: Configure mount paths via settings (recommended for persistent configuration)
427+
github_mcp.settings.mount_path = "/github"
428+
browser_mcp.settings.mount_path = "/browser"
429+
430+
# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting)
431+
# This approach doesn't modify the server's settings permanently
432+
433+
# Create Starlette app with multiple mounted servers
434+
app = Starlette(
435+
routes=[
436+
# Using settings-based configuration
437+
Mount("/github", app=github_mcp.sse_app()),
438+
Mount("/browser", app=browser_mcp.sse_app()),
439+
# Using direct mount path parameter
440+
Mount("/curl", app=curl_mcp.sse_app("/curl")),
441+
Mount("/search", app=search_mcp.sse_app("/search")),
442+
]
443+
)
444+
445+
# Method 3: For direct execution, you can also pass the mount path to run()
446+
if __name__ == "__main__":
447+
search_mcp.run(transport="sse", mount_path="/search")
448+
```
449+
413450
For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes).
414451

415452
#### Message Dispatch Options

src/mcp/server/fastmcp/server.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
8888
# HTTP settings
8989
host: str = "0.0.0.0"
9090
port: int = 8000
91+
mount_path: str = "/" # Mount path (e.g. "/github", defaults to root path)
9192
sse_path: str = "/sse"
9293
message_path: str = "/messages/"
9394

@@ -184,11 +185,16 @@ def name(self) -> str:
184185
def instructions(self) -> str | None:
185186
return self._mcp_server.instructions
186187

187-
def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
188+
def run(
189+
self,
190+
transport: Literal["stdio", "sse"] = "stdio",
191+
mount_path: str | None = None,
192+
) -> None:
188193
"""Run the FastMCP server. Note this is a synchronous function.
189194
190195
Args:
191196
transport: Transport protocol to use ("stdio" or "sse")
197+
mount_path: Optional mount path for SSE transport
192198
"""
193199
TRANSPORTS = Literal["stdio", "sse"]
194200
if transport not in TRANSPORTS.__args__: # type: ignore
@@ -197,7 +203,7 @@ def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
197203
if transport == "stdio":
198204
anyio.run(self.run_stdio_async)
199205
else: # transport == "sse"
200-
anyio.run(self.run_sse_async)
206+
anyio.run(lambda: self.run_sse_async(mount_path))
201207

202208
def _setup_handlers(self) -> None:
203209
"""Set up core MCP protocol handlers."""
@@ -558,11 +564,11 @@ async def run_stdio_async(self) -> None:
558564
self._mcp_server.create_initialization_options(),
559565
)
560566

561-
async def run_sse_async(self) -> None:
567+
async def run_sse_async(self, mount_path: str | None = None) -> None:
562568
"""Run the server using SSE transport."""
563569
import uvicorn
564570

565-
starlette_app = self.sse_app()
571+
starlette_app = self.sse_app(mount_path)
566572

567573
config = uvicorn.Config(
568574
starlette_app,
@@ -573,7 +579,33 @@ async def run_sse_async(self) -> None:
573579
server = uvicorn.Server(config)
574580
await server.serve()
575581

576-
def sse_app(self) -> Starlette:
582+
def _normalize_path(self, mount_path: str, endpoint: str) -> str:
583+
"""
584+
Combine mount path and endpoint to return a normalized path.
585+
586+
Args:
587+
mount_path: The mount path (e.g. "/github" or "/")
588+
endpoint: The endpoint path (e.g. "/messages/")
589+
590+
Returns:
591+
Normalized path (e.g. "/github/messages/")
592+
"""
593+
# Special case: root path
594+
if mount_path == "/":
595+
return endpoint
596+
597+
# Remove trailing slash from mount path
598+
if mount_path.endswith("/"):
599+
mount_path = mount_path[:-1]
600+
601+
# Ensure endpoint starts with slash
602+
if not endpoint.startswith("/"):
603+
endpoint = "/" + endpoint
604+
605+
# Combine paths
606+
return mount_path + endpoint
607+
608+
def sse_app(self, mount_path: str | None = None) -> Starlette:
577609
"""Return an instance of the SSE server app."""
578610
message_dispatch = self.settings.message_dispatch
579611
if message_dispatch is None:
@@ -585,10 +617,20 @@ def sse_app(self) -> Starlette:
585617
from starlette.middleware import Middleware
586618
from starlette.routing import Mount, Route
587619

620+
# Update mount_path in settings if provided
621+
if mount_path is not None:
622+
self.settings.mount_path = mount_path
623+
624+
# Create normalized endpoint considering the mount path
625+
normalized_message_endpoint = self._normalize_path(
626+
self.settings.mount_path, self.settings.message_path
627+
)
628+
588629
# Set up auth context and dependencies
589630

590631
sse = SseServerTransport(
591-
self.settings.message_path, message_dispatch=message_dispatch
632+
normalized_message_endpoint,
633+
message_dispatch=message_dispatch
592634
)
593635

594636
async def handle_sse(scope: Scope, receive: Receive, send: Send):

tests/server/fastmcp/test_server.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import base64
22
from pathlib import Path
33
from typing import TYPE_CHECKING
4+
from unittest.mock import patch
45

56
import pytest
67
from pydantic import AnyUrl
8+
from starlette.routing import Mount, Route
79

810
from mcp.server.fastmcp import Context, FastMCP
911
from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
@@ -31,6 +33,97 @@ async def test_create_server(self):
3133
assert mcp.name == "FastMCP"
3234
assert mcp.instructions == "Server instructions"
3335

36+
@pytest.mark.anyio
37+
async def test_normalize_path(self):
38+
"""Test path normalization for mount paths."""
39+
mcp = FastMCP()
40+
41+
# Test root path
42+
assert mcp._normalize_path("/", "/messages/") == "/messages/"
43+
44+
# Test path with trailing slash
45+
assert mcp._normalize_path("/github/", "/messages/") == "/github/messages/"
46+
47+
# Test path without trailing slash
48+
assert mcp._normalize_path("/github", "/messages/") == "/github/messages/"
49+
50+
# Test endpoint without leading slash
51+
assert mcp._normalize_path("/github", "messages/") == "/github/messages/"
52+
53+
# Test both with trailing/leading slashes
54+
assert mcp._normalize_path("/api/", "/v1/") == "/api/v1/"
55+
56+
@pytest.mark.anyio
57+
async def test_sse_app_with_mount_path(self):
58+
"""Test SSE app creation with different mount paths."""
59+
# Test with default mount path
60+
mcp = FastMCP()
61+
with patch.object(
62+
mcp, "_normalize_path", return_value="/messages/"
63+
) as mock_normalize:
64+
mcp.sse_app()
65+
# Verify _normalize_path was called with correct args
66+
mock_normalize.assert_called_once_with("/", "/messages/")
67+
68+
# Test with custom mount path in settings
69+
mcp = FastMCP()
70+
mcp.settings.mount_path = "/custom"
71+
with patch.object(
72+
mcp, "_normalize_path", return_value="/custom/messages/"
73+
) as mock_normalize:
74+
mcp.sse_app()
75+
# Verify _normalize_path was called with correct args
76+
mock_normalize.assert_called_once_with("/custom", "/messages/")
77+
78+
# Test with mount_path parameter
79+
mcp = FastMCP()
80+
with patch.object(
81+
mcp, "_normalize_path", return_value="/param/messages/"
82+
) as mock_normalize:
83+
mcp.sse_app(mount_path="/param")
84+
# Verify _normalize_path was called with correct args
85+
mock_normalize.assert_called_once_with("/param", "/messages/")
86+
87+
@pytest.mark.anyio
88+
async def test_starlette_routes_with_mount_path(self):
89+
"""Test that Starlette routes are correctly configured with mount path."""
90+
# Test with mount path in settings
91+
mcp = FastMCP()
92+
mcp.settings.mount_path = "/api"
93+
app = mcp.sse_app()
94+
95+
# Find routes by type
96+
sse_routes = [r for r in app.routes if isinstance(r, Route)]
97+
mount_routes = [r for r in app.routes if isinstance(r, Mount)]
98+
99+
# Verify routes exist
100+
assert len(sse_routes) == 1, "Should have one SSE route"
101+
assert len(mount_routes) == 1, "Should have one mount route"
102+
103+
# Verify path values
104+
assert sse_routes[0].path == "/sse", "SSE route path should be /sse"
105+
assert (
106+
mount_routes[0].path == "/messages"
107+
), "Mount route path should be /messages"
108+
109+
# Test with mount path as parameter
110+
mcp = FastMCP()
111+
app = mcp.sse_app(mount_path="/param")
112+
113+
# Find routes by type
114+
sse_routes = [r for r in app.routes if isinstance(r, Route)]
115+
mount_routes = [r for r in app.routes if isinstance(r, Mount)]
116+
117+
# Verify routes exist
118+
assert len(sse_routes) == 1, "Should have one SSE route"
119+
assert len(mount_routes) == 1, "Should have one mount route"
120+
121+
# Verify path values
122+
assert sse_routes[0].path == "/sse", "SSE route path should be /sse"
123+
assert (
124+
mount_routes[0].path == "/messages"
125+
), "Mount route path should be /messages"
126+
34127
@pytest.mark.anyio
35128
async def test_non_ascii_description(self):
36129
"""Test that FastMCP handles non-ASCII characters in descriptions correctly"""
@@ -518,8 +611,6 @@ async def async_tool(x: int, ctx: Context) -> str:
518611

519612
@pytest.mark.anyio
520613
async def test_context_logging(self):
521-
from unittest.mock import patch
522-
523614
import mcp.server.session
524615

525616
"""Test that context logging methods work."""

0 commit comments

Comments
 (0)