Revert mount_path parameter from FastMCP (#1881)
This commit is contained in:
parent
2b4c7ebc61
commit
edf0950a6a
4 changed files with 25 additions and 141 deletions
25
README.md
25
README.md
|
|
@ -1078,7 +1078,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
|
||||||
- `debug` - Debug mode flag
|
- `debug` - Debug mode flag
|
||||||
- `log_level` - Current logging level
|
- `log_level` - Current logging level
|
||||||
- `host` and `port` - Server network configuration
|
- `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
|
- `stateless_http` - Whether the server operates in stateless mode
|
||||||
- And other configuration options
|
- And other configuration options
|
||||||
|
|
||||||
|
|
@ -1614,7 +1614,7 @@ 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:
|
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
|
```python
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
|
|
@ -1624,31 +1624,18 @@ from mcp.server.fastmcp import FastMCP
|
||||||
# Create multiple MCP servers
|
# Create multiple MCP servers
|
||||||
github_mcp = FastMCP("GitHub API")
|
github_mcp = FastMCP("GitHub API")
|
||||||
browser_mcp = FastMCP("Browser")
|
browser_mcp = FastMCP("Browser")
|
||||||
curl_mcp = FastMCP("Curl")
|
|
||||||
search_mcp = FastMCP("Search")
|
search_mcp = FastMCP("Search")
|
||||||
|
|
||||||
# Method 1: Configure mount paths via settings (recommended for persistent configuration)
|
# Mount each server at its own sub-path
|
||||||
github_mcp.settings.mount_path = "/github"
|
# The SSE transport automatically uses ASGI's root_path to construct
|
||||||
browser_mcp.settings.mount_path = "/browser"
|
# the correct message endpoint (e.g., /github/messages/, /browser/messages/)
|
||||||
|
|
||||||
# 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(
|
app = Starlette(
|
||||||
routes=[
|
routes=[
|
||||||
# Using settings-based configuration
|
|
||||||
Mount("/github", app=github_mcp.sse_app()),
|
Mount("/github", app=github_mcp.sse_app()),
|
||||||
Mount("/browser", app=browser_mcp.sse_app()),
|
Mount("/browser", app=browser_mcp.sse_app()),
|
||||||
# Using direct mount path parameter
|
Mount("/search", app=search_mcp.sse_app()),
|
||||||
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).
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,12 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next
|
||||||
result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token"))
|
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`
|
### 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
|
||||||
# HTTP settings
|
# HTTP settings
|
||||||
host: str
|
host: str
|
||||||
port: int
|
port: int
|
||||||
mount_path: str
|
|
||||||
sse_path: str
|
sse_path: str
|
||||||
message_path: str
|
message_path: str
|
||||||
streamable_http_path: str
|
streamable_http_path: str
|
||||||
|
|
@ -138,7 +137,6 @@ class FastMCP(Generic[LifespanResultT]):
|
||||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
|
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
|
||||||
host: str = "127.0.0.1",
|
host: str = "127.0.0.1",
|
||||||
port: int = 8000,
|
port: int = 8000,
|
||||||
mount_path: str = "/",
|
|
||||||
sse_path: str = "/sse",
|
sse_path: str = "/sse",
|
||||||
message_path: str = "/messages/",
|
message_path: str = "/messages/",
|
||||||
streamable_http_path: str = "/mcp",
|
streamable_http_path: str = "/mcp",
|
||||||
|
|
@ -164,7 +162,6 @@ class FastMCP(Generic[LifespanResultT]):
|
||||||
log_level=log_level,
|
log_level=log_level,
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
mount_path=mount_path,
|
|
||||||
sse_path=sse_path,
|
sse_path=sse_path,
|
||||||
message_path=message_path,
|
message_path=message_path,
|
||||||
streamable_http_path=streamable_http_path,
|
streamable_http_path=streamable_http_path,
|
||||||
|
|
@ -269,13 +266,11 @@ class FastMCP(Generic[LifespanResultT]):
|
||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
|
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
|
||||||
mount_path: str | None = 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", "sse", or "streamable-http")
|
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
|
||||||
mount_path: Optional mount path for SSE transport
|
|
||||||
"""
|
"""
|
||||||
TRANSPORTS = Literal["stdio", "sse", "streamable-http"]
|
TRANSPORTS = Literal["stdio", "sse", "streamable-http"]
|
||||||
if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover
|
if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover
|
||||||
|
|
@ -285,7 +280,7 @@ class FastMCP(Generic[LifespanResultT]):
|
||||||
case "stdio":
|
case "stdio":
|
||||||
anyio.run(self.run_stdio_async)
|
anyio.run(self.run_stdio_async)
|
||||||
case "sse": # pragma: no cover
|
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
|
case "streamable-http": # pragma: no cover
|
||||||
anyio.run(self.run_streamable_http_async)
|
anyio.run(self.run_streamable_http_async)
|
||||||
|
|
||||||
|
|
@ -749,11 +744,11 @@ class FastMCP(Generic[LifespanResultT]):
|
||||||
self._mcp_server.create_initialization_options(),
|
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."""
|
"""Run the server using SSE transport."""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
starlette_app = self.sse_app(mount_path)
|
starlette_app = self.sse_app()
|
||||||
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
starlette_app,
|
starlette_app,
|
||||||
|
|
@ -779,58 +774,16 @@ class FastMCP(Generic[LifespanResultT]):
|
||||||
server = uvicorn.Server(config)
|
server = uvicorn.Server(config)
|
||||||
await server.serve()
|
await server.serve()
|
||||||
|
|
||||||
def _normalize_path(self, mount_path: str, endpoint: str) -> str:
|
def sse_app(self) -> Starlette:
|
||||||
"""
|
|
||||||
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."""
|
||||||
|
|
||||||
# Update mount_path in settings if provided
|
sse = SseServerTransport(self.settings.message_path, security_settings=self.settings.transport_security)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
|
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
|
||||||
# Add client ID from auth context into request context if available
|
# Add client ID from auth context into request context if available
|
||||||
|
|
||||||
async with sse.connect_sse(scope, receive, send) as streams:
|
async with sse.connect_sse(scope, receive, send) as streams:
|
||||||
await self._mcp_server.run(
|
await self._mcp_server.run(streams[0], streams[1], self._mcp_server.create_initialization_options())
|
||||||
streams[0],
|
|
||||||
streams[1],
|
|
||||||
self._mcp_server.create_initialization_options(),
|
|
||||||
)
|
|
||||||
return Response()
|
return Response()
|
||||||
|
|
||||||
# Create routes
|
# Create routes
|
||||||
|
|
|
||||||
|
|
@ -49,85 +49,23 @@ class TestServer:
|
||||||
assert mcp.icons[0].src == "https://example.com/icon.png"
|
assert mcp.icons[0].src == "https://example.com/icon.png"
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_normalize_path(self):
|
async def test_sse_app_returns_starlette_app(self):
|
||||||
"""Test path normalization for mount paths."""
|
"""Test that sse_app returns a Starlette application with correct routes."""
|
||||||
mcp = FastMCP()
|
from starlette.applications import Starlette
|
||||||
|
|
||||||
# Test root path
|
mcp = FastMCP("test", host="0.0.0.0") # Use 0.0.0.0 to avoid auto DNS protection
|
||||||
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()
|
app = mcp.sse_app()
|
||||||
|
|
||||||
# Find routes by type
|
assert isinstance(app, Starlette)
|
||||||
|
|
||||||
|
# Verify routes exist
|
||||||
sse_routes = [r for r in app.routes if isinstance(r, Route)]
|
sse_routes = [r for r in app.routes if isinstance(r, Route)]
|
||||||
mount_routes = [r for r in app.routes if isinstance(r, Mount)]
|
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(sse_routes) == 1, "Should have one SSE route"
|
||||||
assert len(mount_routes) == 1, "Should have one mount route"
|
assert len(mount_routes) == 1, "Should have one mount route"
|
||||||
|
assert sse_routes[0].path == "/sse"
|
||||||
# Verify path values
|
assert mount_routes[0].path == "/messages"
|
||||||
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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue