Skip to content

Commit 3b9eea0

Browse files
style: ruff format and lint; clarify SSE relative endpoint comments and tests
1 parent 6ac9017 commit 3b9eea0

File tree

3 files changed

+63
-36
lines changed

3 files changed

+63
-36
lines changed

src/mcp/server/sse.py

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22
SSE Server Transport Module
33
44
This module implements a Server-Sent Events (SSE) transport layer for MCP servers.
5-
Fixes the URL path joining issue when using subpaths/proxied servers.
5+
Endpoints are specified as relative paths. This aligns with common client URL
6+
construction patterns (for example, `urllib.parse.urljoin`) and works correctly
7+
when applications are deployed behind proxies or at subpaths.
68
79
Example usage:
810
```python
9-
# Option 1: Create an SSE transport with absolute path (leading slash)
10-
# This treats "/messages/" as absolute within the app
11-
sse = SseServerTransport("/messages/")
12-
13-
# Option 2: Create an SSE transport with relative path (no leading slash)
14-
# This treats "messages/" as relative to the root path - RECOMMENDED for proxied servers
11+
# Recommended: provide a relative path segment (no scheme/host/query/fragment).
12+
# Using "messages/" works well with clients that build absolute URLs using
13+
# `urllib.parse.urljoin`, including in proxied/subpath deployments.
1514
sse = SseServerTransport("messages/")
1615
1716
# Create Starlette routes for SSE and message handling
@@ -36,14 +35,16 @@ async def handle_sse(request):
3635
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
3736
```
3837
39-
Path behavior examples:
40-
- With root_path="" and endpoint="/messages/": Final path = "/messages/"
41-
- With root_path="" and endpoint="messages/": Final path = "/messages/"
42-
- With root_path="/api" and endpoint="/messages/": Final path = "/api/messages/"
43-
- With root_path="/api" and endpoint="messages/": Final path = "/api/messages/"
38+
Path behavior examples inside the server (final path emitted to clients):
39+
- root_path="" and endpoint="messages/" -> "/messages/"
40+
- root_path="/api" and endpoint="messages/" -> "/api/messages/"
41+
42+
Note: When clients use `urllib.parse.urljoin(base, path)`, joining a segment that
43+
starts with "/" replaces the base path. Providing a relative segment like
44+
`"messages/?id=1"` preserves the base path as intended.
4445
45-
For servers behind proxies or mounted at subpaths, use the relative path format
46-
(without leading slash) to ensure proper URL joining with urllib.parse.urljoin().
46+
For servers behind proxies or mounted at subpaths, prefer a relative path without
47+
leading slash (e.g., "messages/") to ensure correct joining with `urljoin`.
4748
4849
Note: The handle_sse function must return a Response to avoid a "TypeError: 'NoneType'
4950
object is not callable" error when client disconnects. The example above returns
@@ -98,8 +99,10 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
9899
messages to the relative path given.
99100
100101
Args:
101-
endpoint: A relative path where messages should be posted
102-
(e.g., "/messages/" or "messages/").
102+
endpoint: Relative path segment where messages should be posted
103+
(e.g., "messages/"). Avoid scheme/host/query/fragment. When
104+
clients construct absolute URLs using `urllib.parse.urljoin`,
105+
relative segments preserve any existing base path.
103106
security_settings: Optional security settings for DNS rebinding protection.
104107
105108
Note:
@@ -111,25 +114,24 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
111114
3. Portability: The same endpoint configuration works across different
112115
environments (development, staging, production)
113116
114-
The endpoint path handling has been updated to work correctly with urllib.parse.urljoin()
115-
when servers are behind proxies or mounted at subpaths.
117+
The endpoint path handling preserves the provided relative path and is
118+
suitable for deployments under proxies or subpaths.
116119
117120
Raises:
118121
ValueError: If the endpoint is a full URL instead of a relative path
119122
"""
120123

121124
super().__init__()
122125

123-
# Validate that endpoint is a relative path and not a full URL
126+
# Validate that endpoint is a relative path and not a full URL.
124127
if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint:
125128
raise ValueError(
126-
f"Given endpoint: {endpoint} is not a relative path (e.g., '/messages/' or 'messages/'), "
127-
"expecting a relative path (e.g., '/messages/' or 'messages/')."
129+
f"Given endpoint: {endpoint} is not a relative path (e.g., 'messages/'), "
130+
"expecting a relative path with no scheme/host/query/fragment."
128131
)
129132

130-
# Handle leading slash more intelligently
131-
# Remove automatic leading slash enforcement to support proper URL joining
132-
# Store the endpoint as-is, allowing both "/messages/" and "messages/" formats
133+
# Store the endpoint as provided to retain relative-path semantics and make
134+
# client URL construction predictable across deployment topologies.
133135
self._endpoint = endpoint
134136
self._read_stream_writers = {}
135137
self._security = TransportSecurityMiddleware(security_settings)
@@ -139,8 +141,9 @@ def _build_message_path(self, root_path: str) -> str:
139141
"""
140142
Helper method to properly construct the message path
141143
142-
This method handles the path construction logic that was causing issues
143-
with urllib.parse.urljoin() when servers are proxied or mounted at subpaths.
144+
Constructs the message path relative to the app's mount point and the
145+
provided `root_path`. The stored endpoint is treated as path-absolute if
146+
it starts with "/", otherwise as a relative segment.
144147
145148
Args:
146149
root_path: The root path from ASGI scope (e.g., "" or "/api_prefix")
@@ -151,10 +154,10 @@ def _build_message_path(self, root_path: str) -> str:
151154
# Clean up the root path
152155
clean_root_path = root_path.rstrip("/")
153156

154-
# If endpoint starts with "/", it's meant to be absolute within the app
155-
# If endpoint doesn't start with "/", it's meant to be relative to root_path
157+
# If endpoint starts with "/", treat it as path-absolute from the app mount;
158+
# otherwise, treat it as relative to `root_path`.
156159
if self._endpoint.startswith("/"):
157-
# Absolute path within the app - just concatenate
160+
# Path-absolute within the app mount - just concatenate
158161
full_path = clean_root_path + self._endpoint
159162
else:
160163
# Relative path - ensure proper joining

tests/server/test_sse_security.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,21 @@ async def test_sse_security_post_valid_content_type(server_port: int):
295295

296296
@pytest.mark.anyio
297297
async def test_endpoint_validation_rejects_absolute_urls():
298-
"""Test that SseServerTransport properly validates endpoint format."""
299-
# These should all raise ValueError due to being absolute URLs or having invalid characters
298+
"""Validate endpoint format: relative path segments only.
299+
300+
Context on URL joining (urllib.parse.urljoin):
301+
- Joining a segment starting with "/" resets to the host root:
302+
urljoin("http://host/app/sse", "/messages") -> "http://host/messages"
303+
- Joining a relative segment appends relative to the base:
304+
urljoin("http://host/hello/world", "messages") -> "http://host/hello/messages"
305+
urljoin("http://host/hello/world/", "messages") -> "http://host/hello/world/messages"
306+
307+
This test ensures the transport accepts relative path segments (e.g., "messages/"),
308+
rejects full URLs or paths containing query/fragment components, and stores accepted
309+
values verbatim (no normalization). Both leading-slash and non-leading-slash forms
310+
are permitted because the server handles construction relative to its mount path.
311+
"""
312+
# Reject: fully-qualified URLs and segments that include query/fragment
300313
invalid_endpoints = [
301314
"http://example.com/messages/",
302315
"https://example.com/messages/",
@@ -309,10 +322,10 @@ async def test_endpoint_validation_rejects_absolute_urls():
309322
with pytest.raises(ValueError, match="is not a relative path"):
310323
SseServerTransport(invalid_endpoint)
311324

312-
# These should all be valid - endpoint is stored as-is (no automatic normalization)
325+
# Accept: relative path forms; endpoint is stored as provided (no normalization)
313326
valid_endpoints_and_expected = [
314-
("/messages/", "/messages/"), # Absolute path format
315-
("messages/", "messages/"), # Relative path format
327+
("/messages/", "/messages/"), # Leading-slash path segment
328+
("messages/", "messages/"), # Non-leading-slash path segment
316329
("/api/v1/messages/", "/api/v1/messages/"),
317330
("api/v1/messages/", "api/v1/messages/"),
318331
]

tests/shared/test_sse.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ def test_sse_message_id_coercion():
487487
@pytest.mark.parametrize(
488488
"endpoint, expected_result",
489489
[
490-
# These should all be valid - endpoint is stored as-is (no automatic normalization)
490+
# Accept: relative path forms; endpoint is stored verbatim (no normalization)
491491
("/messages/", "/messages/"),
492492
("messages/", "messages/"),
493493
("/", "/"),
@@ -500,7 +500,18 @@ def test_sse_message_id_coercion():
500500
],
501501
)
502502
def test_sse_server_transport_endpoint_validation(endpoint: str, expected_result: str | type[Exception]):
503-
"""Test that SseServerTransport properly validates and normalizes endpoints."""
503+
"""Validate relative endpoint semantics and storage.
504+
505+
Context on URL joining (urllib.parse.urljoin):
506+
- Joining a segment starting with "/" resets to the host root:
507+
urljoin("http://host/hello/world", "/messages") -> "http://host/messages"
508+
- Joining a relative segment appends relative to the base:
509+
urljoin("http://host/hello/world", "messages") -> "http://host/hello/messages"
510+
urljoin("http://host/hello/world/", "messages/") -> "http://host/hello/world/messages/"
511+
512+
The transport validates that endpoints are relative path segments (no scheme/host/query/fragment)
513+
and stores accepted values exactly as provided.
514+
"""
504515
if isinstance(expected_result, type) and issubclass(expected_result, Exception):
505516
# Test invalid endpoints that should raise an exception
506517
with pytest.raises(expected_result, match="is not a relative path.*expecting a relative path"):

0 commit comments

Comments
 (0)