From ebd10b3b715b9570dcb5e84da6349d1ee7a22148 Mon Sep 17 00:00:00 2001 From: Sourabh Mishra Date: Tue, 29 Jul 2025 01:01:54 +0530 Subject: [PATCH 1/6] fix: resolve URL path truncation in SSE transport for proxied servers --- src/mcp/server/sse.py | 82 +++++++++++++++++++++++-------- tests/server/test_sse_security.py | 31 ++++++++++++ tests/shared/test_sse.py | 7 ++- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index b7ff33280..5a17f5c55 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -1,13 +1,19 @@ """ -SSE Server Transport Module +SSE Server Transport Module - Fixed Version This module implements a Server-Sent Events (SSE) transport layer for MCP servers. +Fixes the URL path joining issue when using subpaths/proxied servers. Example usage: -``` - # Create an SSE transport at an endpoint +```python + # Option 1: Create an SSE transport with absolute path (leading slash) + # This treats "/messages/" as absolute within the app sse = SseServerTransport("/messages/") + # Option 2: Create an SSE transport with relative path (no leading slash) + # This treats "messages/" as relative to the root path - RECOMMENDED for proxied servers + sse = SseServerTransport("messages/") + # Create Starlette routes for SSE and message handling routes = [ Route("/sse", endpoint=handle_sse, methods=["GET"]), @@ -30,6 +36,15 @@ async def handle_sse(request): uvicorn.run(starlette_app, host="127.0.0.1", port=port) ``` +Path behavior examples: +- With root_path="" and endpoint="/messages/": Final path = "/messages/" +- With root_path="" and endpoint="messages/": Final path = "/messages/" +- With root_path="/api" and endpoint="/messages/": Final path = "/api/messages/" +- With root_path="/api" and endpoint="messages/": Final path = "/api/messages/" + +For servers behind proxies or mounted at subpaths, use the relative path format +(without leading slash) to ensure proper URL joining with urllib.parse.urljoin(). + Note: The handle_sse function must return a Response to avoid a "TypeError: 'NoneType' object is not callable" error when client disconnects. The example above returns an empty Response() after the SSE connection ends to fix this. @@ -84,7 +99,7 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | Args: endpoint: A relative path where messages should be posted - (e.g., "/messages/"). + (e.g., "/messages/" or "messages/"). security_settings: Optional security settings for DNS rebinding protection. Note: @@ -96,6 +111,9 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | 3. Portability: The same endpoint configuration works across different environments (development, staging, production) + The endpoint path handling has been updated to work correctly with urllib.parse.urljoin() + when servers are behind proxies or mounted at subpaths. + Raises: ValueError: If the endpoint is a full URL instead of a relative path """ @@ -105,19 +123,49 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | # Validate that endpoint is a relative path and not a full URL if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint: raise ValueError( - f"Given endpoint: {endpoint} is not a relative path (e.g., '/messages/'), " - "expecting a relative path (e.g., '/messages/')." + f"Given endpoint: {endpoint} is not a relative path (e.g., '/messages/' or 'messages/'), " + "expecting a relative path (e.g., '/messages/' or 'messages/')." ) - # Ensure endpoint starts with a forward slash - if not endpoint.startswith("/"): - endpoint = "/" + endpoint - + # Handle leading slash more intelligently + # Remove automatic leading slash enforcement to support proper URL joining + # Store the endpoint as-is, allowing both "/messages/" and "messages/" formats self._endpoint = endpoint + self._read_stream_writers = {} self._security = TransportSecurityMiddleware(security_settings) logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}") + def _build_message_path(self, root_path: str) -> str: + """ + Helper method to properly construct the message path + + This method handles the path construction logic that was causing issues + with urllib.parse.urljoin() when servers are proxied or mounted at subpaths. + + Args: + root_path: The root path from ASGI scope (e.g., "" or "/api_prefix") + + Returns: + The properly constructed path for client message posting + """ + # Clean up the root path + clean_root_path = root_path.rstrip("/") + + # If endpoint starts with "/", it's meant to be absolute within the app + # If endpoint doesn't start with "/", it's meant to be relative to root_path + if self._endpoint.startswith("/"): + # Absolute path within the app - just concatenate + full_path = clean_root_path + self._endpoint + else: + # Relative path - ensure proper joining + if clean_root_path: + full_path = clean_root_path + "/" + self._endpoint + else: + full_path = "/" + self._endpoint + + return full_path + @asynccontextmanager async def connect_sse(self, scope: Scope, receive: Receive, send: Send): if scope["type"] != "http": @@ -145,17 +193,9 @@ async def connect_sse(self, scope: Scope, receive: Receive, send: Send): self._read_stream_writers[session_id] = read_stream_writer logger.debug(f"Created new session with ID: {session_id}") - # Determine the full path for the message endpoint to be sent to the client. - # scope['root_path'] is the prefix where the current Starlette app - # instance is mounted. - # e.g., "" if top-level, or "/api_prefix" if mounted under "/api_prefix". + # Use the new helper method for proper path construction root_path = scope.get("root_path", "") - - # self._endpoint is the path *within* this app, e.g., "/messages". - # Concatenating them gives the full absolute path from the server root. - # e.g., "" + "/messages" -> "/messages" - # e.g., "/api_prefix" + "/messages" -> "/api_prefix/messages" - full_message_path_for_client = root_path.rstrip("/") + self._endpoint + full_message_path_for_client = self._build_message_path(root_path) # This is the URI (path + query) the client will use to POST messages. client_post_uri_data = f"{quote(full_message_path_for_client)}?session_id={session_id.hex}" @@ -246,4 +286,4 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) logger.debug(f"Sending session message to writer: {session_message}") response = Response("Accepted", status_code=202) await response(scope, receive, send) - await writer.send(session_message) + await writer.send(session_message) \ No newline at end of file diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index 43af35061..0432dcd3c 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -291,3 +291,34 @@ async def test_sse_security_post_valid_content_type(server_port: int): finally: process.terminate() process.join() + + +@pytest.mark.anyio +async def test_endpoint_validation_rejects_absolute_urls(): + """Test that SseServerTransport properly validates endpoint format.""" + # These should all raise ValueError due to being absolute URLs or having invalid characters + invalid_endpoints = [ + "http://example.com/messages/", + "https://example.com/messages/", + "//example.com/messages/", + "/messages/?query=test", + "/messages/#fragment", + ] + + for invalid_endpoint in invalid_endpoints: + with pytest.raises(ValueError, match="is not a relative path"): + SseServerTransport(invalid_endpoint) + + # These should all be valid - endpoint is stored as-is (no automatic normalization) + valid_endpoints_and_expected = [ + ("/messages/", "/messages/"), # Absolute path format + ("messages/", "messages/"), # Relative path format + ("/api/v1/messages/", "/api/v1/messages/"), + ("api/v1/messages/", "api/v1/messages/"), + ] + + for valid_endpoint, expected_stored_value in valid_endpoints_and_expected: + # Should not raise an exception + transport = SseServerTransport(valid_endpoint) + # Endpoint should be stored exactly as provided (no normalization) + assert transport._endpoint == expected_stored_value \ No newline at end of file diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 39ae13524..fc4ec18e2 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -487,9 +487,9 @@ def test_sse_message_id_coercion(): @pytest.mark.parametrize( "endpoint, expected_result", [ - # Valid endpoints - should normalize and work + # These should all be valid - endpoint is stored as-is (no automatic normalization) ("/messages/", "/messages/"), - ("messages/", "/messages/"), + ("messages/", "messages/"), ("/", "/"), # Invalid endpoints - should raise ValueError ("http://example.com/messages/", ValueError), @@ -506,7 +506,6 @@ def test_sse_server_transport_endpoint_validation(endpoint: str, expected_result with pytest.raises(expected_result, match="is not a relative path.*expecting a relative path"): SseServerTransport(endpoint) else: - # Test valid endpoints that should normalize correctly + # Endpoint should be stored exactly as provided (no normalization) sse = SseServerTransport(endpoint) assert sse._endpoint == expected_result - assert sse._endpoint.startswith("/") From 3d5d6b7f8cdb22e11123a4b8f4d131147ee550bb Mon Sep 17 00:00:00 2001 From: Sourabh Mishra Date: Tue, 29 Jul 2025 01:06:01 +0530 Subject: [PATCH 2/6] removed unnessary Fixed --- src/mcp/server/sse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 5a17f5c55..1aea6c832 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -1,5 +1,5 @@ """ -SSE Server Transport Module - Fixed Version +SSE Server Transport Module This module implements a Server-Sent Events (SSE) transport layer for MCP servers. Fixes the URL path joining issue when using subpaths/proxied servers. From 4f3be62b3bea754bcb696a203a83a7de8dfeb924 Mon Sep 17 00:00:00 2001 From: Sourabh Mishra Date: Tue, 29 Jul 2025 01:19:55 +0530 Subject: [PATCH 3/6] added correct formating --- src/mcp/server/sse.py | 13 ++++++------- tests/server/test_sse_security.py | 12 ++++++------ tests/shared/test_sse.py | 2 +- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 1aea6c832..3037bfbbc 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -131,7 +131,6 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | # Remove automatic leading slash enforcement to support proper URL joining # Store the endpoint as-is, allowing both "/messages/" and "messages/" formats self._endpoint = endpoint - self._read_stream_writers = {} self._security = TransportSecurityMiddleware(security_settings) logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}") @@ -139,19 +138,19 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | def _build_message_path(self, root_path: str) -> str: """ Helper method to properly construct the message path - + This method handles the path construction logic that was causing issues with urllib.parse.urljoin() when servers are proxied or mounted at subpaths. - + Args: root_path: The root path from ASGI scope (e.g., "" or "/api_prefix") - + Returns: The properly constructed path for client message posting """ # Clean up the root path clean_root_path = root_path.rstrip("/") - + # If endpoint starts with "/", it's meant to be absolute within the app # If endpoint doesn't start with "/", it's meant to be relative to root_path if self._endpoint.startswith("/"): @@ -163,7 +162,7 @@ def _build_message_path(self, root_path: str) -> str: full_path = clean_root_path + "/" + self._endpoint else: full_path = "/" + self._endpoint - + return full_path @asynccontextmanager @@ -286,4 +285,4 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) logger.debug(f"Sending session message to writer: {session_message}") response = Response("Accepted", status_code=202) await response(scope, receive, send) - await writer.send(session_message) \ No newline at end of file + await writer.send(session_message) diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index 0432dcd3c..86d62567a 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -299,26 +299,26 @@ async def test_endpoint_validation_rejects_absolute_urls(): # These should all raise ValueError due to being absolute URLs or having invalid characters invalid_endpoints = [ "http://example.com/messages/", - "https://example.com/messages/", + "https://example.com/messages/", "//example.com/messages/", "/messages/?query=test", "/messages/#fragment", ] - + for invalid_endpoint in invalid_endpoints: with pytest.raises(ValueError, match="is not a relative path"): SseServerTransport(invalid_endpoint) - + # These should all be valid - endpoint is stored as-is (no automatic normalization) valid_endpoints_and_expected = [ ("/messages/", "/messages/"), # Absolute path format - ("messages/", "messages/"), # Relative path format + ("messages/", "messages/"), # Relative path format ("/api/v1/messages/", "/api/v1/messages/"), ("api/v1/messages/", "api/v1/messages/"), ] - + for valid_endpoint, expected_stored_value in valid_endpoints_and_expected: # Should not raise an exception transport = SseServerTransport(valid_endpoint) # Endpoint should be stored exactly as provided (no normalization) - assert transport._endpoint == expected_stored_value \ No newline at end of file + assert transport._endpoint == expected_stored_value diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index fc4ec18e2..16d546b85 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -489,7 +489,7 @@ def test_sse_message_id_coercion(): [ # These should all be valid - endpoint is stored as-is (no automatic normalization) ("/messages/", "/messages/"), - ("messages/", "messages/"), + ("messages/", "messages/"), ("/", "/"), # Invalid endpoints - should raise ValueError ("http://example.com/messages/", ValueError), From 3b9eea066a3e09f67d999a04244a26c52d5cbcd5 Mon Sep 17 00:00:00 2001 From: Sourabh Mishra Date: Sat, 9 Aug 2025 19:32:05 +0530 Subject: [PATCH 4/6] style: ruff format and lint; clarify SSE relative endpoint comments and tests --- src/mcp/server/sse.py | 61 ++++++++++++++++--------------- tests/server/test_sse_security.py | 23 +++++++++--- tests/shared/test_sse.py | 15 +++++++- 3 files changed, 63 insertions(+), 36 deletions(-) diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 3037bfbbc..bb81ea3c1 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -2,16 +2,15 @@ SSE Server Transport Module This module implements a Server-Sent Events (SSE) transport layer for MCP servers. -Fixes the URL path joining issue when using subpaths/proxied servers. +Endpoints are specified as relative paths. This aligns with common client URL +construction patterns (for example, `urllib.parse.urljoin`) and works correctly +when applications are deployed behind proxies or at subpaths. Example usage: ```python - # Option 1: Create an SSE transport with absolute path (leading slash) - # This treats "/messages/" as absolute within the app - sse = SseServerTransport("/messages/") - - # Option 2: Create an SSE transport with relative path (no leading slash) - # This treats "messages/" as relative to the root path - RECOMMENDED for proxied servers + # Recommended: provide a relative path segment (no scheme/host/query/fragment). + # Using "messages/" works well with clients that build absolute URLs using + # `urllib.parse.urljoin`, including in proxied/subpath deployments. sse = SseServerTransport("messages/") # Create Starlette routes for SSE and message handling @@ -36,14 +35,16 @@ async def handle_sse(request): uvicorn.run(starlette_app, host="127.0.0.1", port=port) ``` -Path behavior examples: -- With root_path="" and endpoint="/messages/": Final path = "/messages/" -- With root_path="" and endpoint="messages/": Final path = "/messages/" -- With root_path="/api" and endpoint="/messages/": Final path = "/api/messages/" -- With root_path="/api" and endpoint="messages/": Final path = "/api/messages/" +Path behavior examples inside the server (final path emitted to clients): +- root_path="" and endpoint="messages/" -> "/messages/" +- root_path="/api" and endpoint="messages/" -> "/api/messages/" + +Note: When clients use `urllib.parse.urljoin(base, path)`, joining a segment that +starts with "/" replaces the base path. Providing a relative segment like +`"messages/?id=1"` preserves the base path as intended. -For servers behind proxies or mounted at subpaths, use the relative path format -(without leading slash) to ensure proper URL joining with urllib.parse.urljoin(). +For servers behind proxies or mounted at subpaths, prefer a relative path without +leading slash (e.g., "messages/") to ensure correct joining with `urljoin`. Note: The handle_sse function must return a Response to avoid a "TypeError: 'NoneType' object is not callable" error when client disconnects. The example above returns @@ -98,8 +99,10 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | messages to the relative path given. Args: - endpoint: A relative path where messages should be posted - (e.g., "/messages/" or "messages/"). + endpoint: Relative path segment where messages should be posted + (e.g., "messages/"). Avoid scheme/host/query/fragment. When + clients construct absolute URLs using `urllib.parse.urljoin`, + relative segments preserve any existing base path. security_settings: Optional security settings for DNS rebinding protection. Note: @@ -111,8 +114,8 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | 3. Portability: The same endpoint configuration works across different environments (development, staging, production) - The endpoint path handling has been updated to work correctly with urllib.parse.urljoin() - when servers are behind proxies or mounted at subpaths. + The endpoint path handling preserves the provided relative path and is + suitable for deployments under proxies or subpaths. Raises: ValueError: If the endpoint is a full URL instead of a relative path @@ -120,16 +123,15 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | super().__init__() - # Validate that endpoint is a relative path and not a full URL + # Validate that endpoint is a relative path and not a full URL. if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint: raise ValueError( - f"Given endpoint: {endpoint} is not a relative path (e.g., '/messages/' or 'messages/'), " - "expecting a relative path (e.g., '/messages/' or 'messages/')." + f"Given endpoint: {endpoint} is not a relative path (e.g., 'messages/'), " + "expecting a relative path with no scheme/host/query/fragment." ) - # Handle leading slash more intelligently - # Remove automatic leading slash enforcement to support proper URL joining - # Store the endpoint as-is, allowing both "/messages/" and "messages/" formats + # Store the endpoint as provided to retain relative-path semantics and make + # client URL construction predictable across deployment topologies. self._endpoint = endpoint self._read_stream_writers = {} self._security = TransportSecurityMiddleware(security_settings) @@ -139,8 +141,9 @@ def _build_message_path(self, root_path: str) -> str: """ Helper method to properly construct the message path - This method handles the path construction logic that was causing issues - with urllib.parse.urljoin() when servers are proxied or mounted at subpaths. + Constructs the message path relative to the app's mount point and the + provided `root_path`. The stored endpoint is treated as path-absolute if + it starts with "/", otherwise as a relative segment. Args: 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: # Clean up the root path clean_root_path = root_path.rstrip("/") - # If endpoint starts with "/", it's meant to be absolute within the app - # If endpoint doesn't start with "/", it's meant to be relative to root_path + # If endpoint starts with "/", treat it as path-absolute from the app mount; + # otherwise, treat it as relative to `root_path`. if self._endpoint.startswith("/"): - # Absolute path within the app - just concatenate + # Path-absolute within the app mount - just concatenate full_path = clean_root_path + self._endpoint else: # Relative path - ensure proper joining diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index 86d62567a..8cbe20398 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -295,8 +295,21 @@ async def test_sse_security_post_valid_content_type(server_port: int): @pytest.mark.anyio async def test_endpoint_validation_rejects_absolute_urls(): - """Test that SseServerTransport properly validates endpoint format.""" - # These should all raise ValueError due to being absolute URLs or having invalid characters + """Validate endpoint format: relative path segments only. + + Context on URL joining (urllib.parse.urljoin): + - Joining a segment starting with "/" resets to the host root: + urljoin("http://host/app/sse", "/messages") -> "http://host/messages" + - Joining a relative segment appends relative to the base: + urljoin("http://host/hello/world", "messages") -> "http://host/hello/messages" + urljoin("http://host/hello/world/", "messages") -> "http://host/hello/world/messages" + + This test ensures the transport accepts relative path segments (e.g., "messages/"), + rejects full URLs or paths containing query/fragment components, and stores accepted + values verbatim (no normalization). Both leading-slash and non-leading-slash forms + are permitted because the server handles construction relative to its mount path. + """ + # Reject: fully-qualified URLs and segments that include query/fragment invalid_endpoints = [ "http://example.com/messages/", "https://example.com/messages/", @@ -309,10 +322,10 @@ async def test_endpoint_validation_rejects_absolute_urls(): with pytest.raises(ValueError, match="is not a relative path"): SseServerTransport(invalid_endpoint) - # These should all be valid - endpoint is stored as-is (no automatic normalization) + # Accept: relative path forms; endpoint is stored as provided (no normalization) valid_endpoints_and_expected = [ - ("/messages/", "/messages/"), # Absolute path format - ("messages/", "messages/"), # Relative path format + ("/messages/", "/messages/"), # Leading-slash path segment + ("messages/", "messages/"), # Non-leading-slash path segment ("/api/v1/messages/", "/api/v1/messages/"), ("api/v1/messages/", "api/v1/messages/"), ] diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 16d546b85..8d00f0963 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -487,7 +487,7 @@ def test_sse_message_id_coercion(): @pytest.mark.parametrize( "endpoint, expected_result", [ - # These should all be valid - endpoint is stored as-is (no automatic normalization) + # Accept: relative path forms; endpoint is stored verbatim (no normalization) ("/messages/", "/messages/"), ("messages/", "messages/"), ("/", "/"), @@ -500,7 +500,18 @@ def test_sse_message_id_coercion(): ], ) def test_sse_server_transport_endpoint_validation(endpoint: str, expected_result: str | type[Exception]): - """Test that SseServerTransport properly validates and normalizes endpoints.""" + """Validate relative endpoint semantics and storage. + + Context on URL joining (urllib.parse.urljoin): + - Joining a segment starting with "/" resets to the host root: + urljoin("http://host/hello/world", "/messages") -> "http://host/messages" + - Joining a relative segment appends relative to the base: + urljoin("http://host/hello/world", "messages") -> "http://host/hello/messages" + urljoin("http://host/hello/world/", "messages/") -> "http://host/hello/world/messages/" + + The transport validates that endpoints are relative path segments (no scheme/host/query/fragment) + and stores accepted values exactly as provided. + """ if isinstance(expected_result, type) and issubclass(expected_result, Exception): # Test invalid endpoints that should raise an exception with pytest.raises(expected_result, match="is not a relative path.*expecting a relative path"): From eaee7806086aae1af650e5ce833c72b1371a4869 Mon Sep 17 00:00:00 2001 From: Sourabh19278 <79500203+SOURABHMISHRA5221@users.noreply.github.com> Date: Tue, 12 Aug 2025 16:39:35 +0530 Subject: [PATCH 5/6] Update test_sse.py --- tests/shared/test_sse.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 65f11293f..9b421c161 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -501,7 +501,6 @@ def test_sse_message_id_coercion(): ], ) def test_sse_server_transport_endpoint_validation(endpoint: str, expected_result: str | type[Exception]): - """Validate relative endpoint semantics and storage. Context on URL joining (urllib.parse.urljoin): From a2b8a2aae85ba4b399c90b1f2e5d1e7f6cdf8b53 Mon Sep 17 00:00:00 2001 From: Sourabh19278 <79500203+SOURABHMISHRA5221@users.noreply.github.com> Date: Tue, 12 Aug 2025 16:42:11 +0530 Subject: [PATCH 6/6] Update test_sse.py fixed pre commit issue --- tests/shared/test_sse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 9b421c161..a68c56b49 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -502,7 +502,7 @@ def test_sse_message_id_coercion(): ) def test_sse_server_transport_endpoint_validation(endpoint: str, expected_result: str | type[Exception]): """Validate relative endpoint semantics and storage. - + Context on URL joining (urllib.parse.urljoin): - Joining a segment starting with "/" resets to the host root: urljoin("http://host/hello/world", "/messages") -> "http://host/messages"