Skip to content

Commit 3d8ff7a

Browse files
committed
Merge branch 'main' into ihrpr/shttp
2 parents 013a295 + a027d75 commit 3d8ff7a

File tree

5 files changed

+248
-11
lines changed

5 files changed

+248
-11
lines changed

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ mcp = FastMCP("My App",
334334
)
335335
```
336336

337-
See [OAuthServerProvider](mcp/server/auth/provider.py) for more details.
337+
See [OAuthServerProvider](src/mcp/server/auth/provider.py) for more details.
338338

339339
## Running Your Server
340340

@@ -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
## Examples

src/mcp/server/auth/middleware/bearer_auth.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,15 @@ def __init__(
3434
self.provider = provider
3535

3636
async def authenticate(self, conn: HTTPConnection):
37-
auth_header = conn.headers.get("Authorization")
38-
if not auth_header or not auth_header.startswith("Bearer "):
37+
auth_header = next(
38+
(
39+
conn.headers.get(key)
40+
for key in conn.headers
41+
if key.lower() == "authorization"
42+
),
43+
None,
44+
)
45+
if not auth_header or not auth_header.lower().startswith("bearer "):
3946
return None
4047

4148
token = auth_header[7:] # Remove "Bearer " prefix

src/mcp/server/fastmcp/server.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
8989
# HTTP settings
9090
host: str = "0.0.0.0"
9191
port: int = 8000
92+
mount_path: str = "/" # Mount path (e.g. "/github", defaults to root path)
9293
sse_path: str = "/sse"
9394
message_path: str = "/messages/"
9495
streamable_http_path: str = "/mcp"
@@ -190,12 +191,15 @@ def instructions(self) -> str | None:
190191
return self._mcp_server.instructions
191192

192193
def run(
193-
self, transport: Literal["stdio", "sse", "streamable-http"] = "stdio"
194+
self,
195+
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
196+
mount_path: str | None = None,
194197
) -> None:
195198
"""Run the FastMCP server. Note this is a synchronous function.
196199
197200
Args:
198201
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
202+
mount_path: Optional mount path for SSE transport
199203
"""
200204
TRANSPORTS = Literal["stdio", "sse", "streamable-http"]
201205
if transport not in TRANSPORTS.__args__: # type: ignore
@@ -205,7 +209,7 @@ def run(
205209
case "stdio":
206210
anyio.run(self.run_stdio_async)
207211
case "sse":
208-
anyio.run(self.run_sse_async)
212+
anyio.run(lambda: self.run_sse_async(mount_path))
209213
case "streamable-http":
210214
anyio.run(self.run_streamable_http_async)
211215

@@ -568,11 +572,11 @@ async def run_stdio_async(self) -> None:
568572
self._mcp_server.create_initialization_options(),
569573
)
570574

571-
async def run_sse_async(self) -> None:
575+
async def run_sse_async(self, mount_path: str | None = None) -> None:
572576
"""Run the server using SSE transport."""
573577
import uvicorn
574578

575-
starlette_app = self.sse_app()
579+
starlette_app = self.sse_app(mount_path)
576580

577581
config = uvicorn.Config(
578582
starlette_app,
@@ -598,14 +602,51 @@ async def run_streamable_http_async(self) -> None:
598602
server = uvicorn.Server(config)
599603
await server.serve()
600604

601-
def sse_app(self) -> Starlette:
605+
def _normalize_path(self, mount_path: str, endpoint: str) -> str:
606+
"""
607+
Combine mount path and endpoint to return a normalized path.
608+
609+
Args:
610+
mount_path: The mount path (e.g. "/github" or "/")
611+
endpoint: The endpoint path (e.g. "/messages/")
612+
613+
Returns:
614+
Normalized path (e.g. "/github/messages/")
615+
"""
616+
# Special case: root path
617+
if mount_path == "/":
618+
return endpoint
619+
620+
# Remove trailing slash from mount path
621+
if mount_path.endswith("/"):
622+
mount_path = mount_path[:-1]
623+
624+
# Ensure endpoint starts with slash
625+
if not endpoint.startswith("/"):
626+
endpoint = "/" + endpoint
627+
628+
# Combine paths
629+
return mount_path + endpoint
630+
631+
def sse_app(self, mount_path: str | None = None) -> Starlette:
602632
"""Return an instance of the SSE server app."""
603633
from starlette.middleware import Middleware
604634
from starlette.routing import Mount, Route
605635

636+
# Update mount_path in settings if provided
637+
if mount_path is not None:
638+
self.settings.mount_path = mount_path
639+
640+
# Create normalized endpoint considering the mount path
641+
normalized_message_endpoint = self._normalize_path(
642+
self.settings.mount_path, self.settings.message_path
643+
)
644+
606645
# Set up auth context and dependencies
607646

608-
sse = SseServerTransport(self.settings.message_path)
647+
sse = SseServerTransport(
648+
normalized_message_endpoint,
649+
)
609650

610651
async def handle_sse(scope: Scope, receive: Receive, send: Send):
611652
# Add client ID from auth context into request context if available

tests/server/auth/middleware/test_bearer_auth.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99
from starlette.authentication import AuthCredentials
10+
from starlette.datastructures import Headers
1011
from starlette.exceptions import HTTPException
1112
from starlette.requests import Request
1213
from starlette.types import Message, Receive, Scope, Send
@@ -221,6 +222,66 @@ async def test_token_without_expiry(
221222
assert user.access_token == no_expiry_access_token
222223
assert user.scopes == ["read", "write"]
223224

225+
async def test_lowercase_bearer_prefix(
226+
self,
227+
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
228+
valid_access_token: AccessToken,
229+
):
230+
"""Test with lowercase 'bearer' prefix in Authorization header"""
231+
backend = BearerAuthBackend(provider=mock_oauth_provider)
232+
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
233+
headers = Headers({"Authorization": "bearer valid_token"})
234+
scope = {"type": "http", "headers": headers.raw}
235+
request = Request(scope)
236+
result = await backend.authenticate(request)
237+
assert result is not None
238+
credentials, user = result
239+
assert isinstance(credentials, AuthCredentials)
240+
assert isinstance(user, AuthenticatedUser)
241+
assert credentials.scopes == ["read", "write"]
242+
assert user.display_name == "test_client"
243+
assert user.access_token == valid_access_token
244+
245+
async def test_mixed_case_bearer_prefix(
246+
self,
247+
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
248+
valid_access_token: AccessToken,
249+
):
250+
"""Test with mixed 'BeArEr' prefix in Authorization header"""
251+
backend = BearerAuthBackend(provider=mock_oauth_provider)
252+
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
253+
headers = Headers({"authorization": "BeArEr valid_token"})
254+
scope = {"type": "http", "headers": headers.raw}
255+
request = Request(scope)
256+
result = await backend.authenticate(request)
257+
assert result is not None
258+
credentials, user = result
259+
assert isinstance(credentials, AuthCredentials)
260+
assert isinstance(user, AuthenticatedUser)
261+
assert credentials.scopes == ["read", "write"]
262+
assert user.display_name == "test_client"
263+
assert user.access_token == valid_access_token
264+
265+
async def test_mixed_case_authorization_header(
266+
self,
267+
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
268+
valid_access_token: AccessToken,
269+
):
270+
"""Test authentication with mixed 'Authorization' header."""
271+
backend = BearerAuthBackend(provider=mock_oauth_provider)
272+
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
273+
headers = Headers({"AuThOrIzAtIoN": "BeArEr valid_token"})
274+
scope = {"type": "http", "headers": headers.raw}
275+
request = Request(scope)
276+
result = await backend.authenticate(request)
277+
assert result is not None
278+
credentials, user = result
279+
assert isinstance(credentials, AuthCredentials)
280+
assert isinstance(user, AuthenticatedUser)
281+
assert credentials.scopes == ["read", "write"]
282+
assert user.display_name == "test_client"
283+
assert user.access_token == valid_access_token
284+
224285

225286
@pytest.mark.anyio
226287
class TestRequireAuthMiddleware:

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)