Skip to content

Commit 8d29bae

Browse files
committed
Revert mount_path parameter from FastMCP (PR #540)
The mount_path parameter added in PR #540 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. The SseServerTransport already uses this to construct the correct message endpoint path (see sse.py lines 148-161). This revert removes: - mount_path setting from Settings class - mount_path parameter from FastMCP.__init__ - mount_path parameter from run() and run_sse_async() - _normalize_path() helper method - mount_path parameter from sse_app() - Related tests and redundant documentation The README documentation is updated to show the correct way to mount multiple MCP servers at different sub-paths, which works out of the box.
1 parent 9f427f3 commit 8d29bae

File tree

3 files changed

+13
-154
lines changed

3 files changed

+13
-154
lines changed

README.md

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
10781078
- `debug` - Debug mode flag
10791079
- `log_level` - Current logging level
10801080
- `host` and `port` - Server network configuration
1081-
- `mount_path`, `sse_path`, `streamable_http_path` - Transport paths
1081+
- `sse_path`, `streamable_http_path` - Transport paths
10821082
- `stateless_http` - Whether the server operates in stateless mode
10831083
- And other configuration options
10841084

@@ -1614,7 +1614,7 @@ app = Starlette(
16141614
app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app()))
16151615
```
16161616

1617-
When mounting multiple MCP servers under different paths, you can configure the mount path in several ways:
1617+
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:
16181618

16191619
```python
16201620
from starlette.applications import Starlette
@@ -1624,31 +1624,18 @@ from mcp.server.fastmcp import FastMCP
16241624
# Create multiple MCP servers
16251625
github_mcp = FastMCP("GitHub API")
16261626
browser_mcp = FastMCP("Browser")
1627-
curl_mcp = FastMCP("Curl")
16281627
search_mcp = FastMCP("Search")
16291628

1630-
# Method 1: Configure mount paths via settings (recommended for persistent configuration)
1631-
github_mcp.settings.mount_path = "/github"
1632-
browser_mcp.settings.mount_path = "/browser"
1633-
1634-
# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting)
1635-
# This approach doesn't modify the server's settings permanently
1636-
1637-
# Create Starlette app with multiple mounted servers
1629+
# Mount each server at its own sub-path
1630+
# The SSE transport automatically uses ASGI's root_path to construct
1631+
# the correct message endpoint (e.g., /github/messages/, /browser/messages/)
16381632
app = Starlette(
16391633
routes=[
1640-
# Using settings-based configuration
16411634
Mount("/github", app=github_mcp.sse_app()),
16421635
Mount("/browser", app=browser_mcp.sse_app()),
1643-
# Using direct mount path parameter
1644-
Mount("/curl", app=curl_mcp.sse_app("/curl")),
1645-
Mount("/search", app=search_mcp.sse_app("/search")),
1636+
Mount("/search", app=search_mcp.sse_app()),
16461637
]
16471638
)
1648-
1649-
# Method 3: For direct execution, you can also pass the mount path to run()
1650-
if __name__ == "__main__":
1651-
search_mcp.run(transport="sse", mount_path="/search")
16521639
```
16531640

16541641
For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes).

src/mcp/server/fastmcp/server.py

Lines changed: 6 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
7676
# HTTP settings
7777
host: str
7878
port: int
79-
mount_path: str
8079
sse_path: str
8180
message_path: str
8281
streamable_http_path: str
@@ -138,7 +137,6 @@ def __init__( # noqa: PLR0913
138137
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
139138
host: str = "127.0.0.1",
140139
port: int = 8000,
141-
mount_path: str = "/",
142140
sse_path: str = "/sse",
143141
message_path: str = "/messages/",
144142
streamable_http_path: str = "/mcp",
@@ -164,7 +162,6 @@ def __init__( # noqa: PLR0913
164162
log_level=log_level,
165163
host=host,
166164
port=port,
167-
mount_path=mount_path,
168165
sse_path=sse_path,
169166
message_path=message_path,
170167
streamable_http_path=streamable_http_path,
@@ -269,13 +266,11 @@ def session_manager(self) -> StreamableHTTPSessionManager:
269266
def run(
270267
self,
271268
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
272-
mount_path: str | None = None,
273269
) -> None:
274270
"""Run the FastMCP server. Note this is a synchronous function.
275271
276272
Args:
277273
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
278-
mount_path: Optional mount path for SSE transport
279274
"""
280275
TRANSPORTS = Literal["stdio", "sse", "streamable-http"]
281276
if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover
@@ -285,7 +280,7 @@ def run(
285280
case "stdio":
286281
anyio.run(self.run_stdio_async)
287282
case "sse": # pragma: no cover
288-
anyio.run(lambda: self.run_sse_async(mount_path))
283+
anyio.run(self.run_sse_async)
289284
case "streamable-http": # pragma: no cover
290285
anyio.run(self.run_streamable_http_async)
291286

@@ -749,11 +744,11 @@ async def run_stdio_async(self) -> None:
749744
self._mcp_server.create_initialization_options(),
750745
)
751746

752-
async def run_sse_async(self, mount_path: str | None = None) -> None: # pragma: no cover
747+
async def run_sse_async(self) -> None: # pragma: no cover
753748
"""Run the server using SSE transport."""
754749
import uvicorn
755750

756-
starlette_app = self.sse_app(mount_path)
751+
starlette_app = self.sse_app()
757752

758753
config = uvicorn.Config(
759754
starlette_app,
@@ -779,58 +774,16 @@ async def run_streamable_http_async(self) -> None: # pragma: no cover
779774
server = uvicorn.Server(config)
780775
await server.serve()
781776

782-
def _normalize_path(self, mount_path: str, endpoint: str) -> str:
783-
"""
784-
Combine mount path and endpoint to return a normalized path.
785-
786-
Args:
787-
mount_path: The mount path (e.g. "/github" or "/")
788-
endpoint: The endpoint path (e.g. "/messages/")
789-
790-
Returns:
791-
Normalized path (e.g. "/github/messages/")
792-
"""
793-
# Special case: root path
794-
if mount_path == "/":
795-
return endpoint
796-
797-
# Remove trailing slash from mount path
798-
if mount_path.endswith("/"):
799-
mount_path = mount_path[:-1]
800-
801-
# Ensure endpoint starts with slash
802-
if not endpoint.startswith("/"):
803-
endpoint = "/" + endpoint
804-
805-
# Combine paths
806-
return mount_path + endpoint
807-
808-
def sse_app(self, mount_path: str | None = None) -> Starlette:
777+
def sse_app(self) -> Starlette:
809778
"""Return an instance of the SSE server app."""
810779

811-
# Update mount_path in settings if provided
812-
if mount_path is not None:
813-
self.settings.mount_path = mount_path
814-
815-
# Create normalized endpoint considering the mount path
816-
normalized_message_endpoint = self._normalize_path(self.settings.mount_path, self.settings.message_path)
817-
818-
# Set up auth context and dependencies
819-
820-
sse = SseServerTransport(
821-
normalized_message_endpoint,
822-
security_settings=self.settings.transport_security,
823-
)
780+
sse = SseServerTransport(self.settings.message_path, security_settings=self.settings.transport_security)
824781

825782
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
826783
# Add client ID from auth context into request context if available
827784

828785
async with sse.connect_sse(scope, receive, send) as streams:
829-
await self._mcp_server.run(
830-
streams[0],
831-
streams[1],
832-
self._mcp_server.create_initialization_options(),
833-
)
786+
await self._mcp_server.run(streams[0], streams[1], self._mcp_server.create_initialization_options())
834787
return Response()
835788

836789
# Create routes

tests/server/fastmcp/test_server.py

Lines changed: 1 addition & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77
from pydantic import BaseModel
8-
from starlette.routing import Mount, Route
8+
from starlette.routing import Route
99

1010
from mcp.server.fastmcp import Context, FastMCP
1111
from mcp.server.fastmcp.prompts.base import Message, UserMessage
@@ -48,87 +48,6 @@ async def test_create_server(self):
4848
assert len(mcp.icons) == 1
4949
assert mcp.icons[0].src == "https://example.com/icon.png"
5050

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

0 commit comments

Comments
 (0)