Skip to content

Commit 6493559

Browse files
n0madgangclaude
andcommitted
fix: include transport path in protected resource metadata URL (RFC 9728)
Per RFC 9728 §3, the `resource` field in `/.well-known/oauth-protected-resource` must identify the actual protected endpoint URL — e.g. `http://localhost:8000/mcp`, not the bare server base `http://localhost:8000/`. Without the path, VS Code Copilot and other spec-compliant clients reject the server with: Protected Resource Metadata resource "http://localhost:8000/" does not match MCP server resolved resource "http://localhost:8000/mcp" Fix: append `streamable_http_path` / `sse_path` to `resource_server_url` before passing it to `create_protected_resource_routes` and `build_resource_metadata_url` in both the lowlevel and mcpserver transports. Fixes #1264 Signed-off-by: Alex Fisch <afischh@gmail.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e8e6484 commit 6493559

3 files changed

Lines changed: 79 additions & 4 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async def main():
4646

4747
import anyio
4848
from opentelemetry.trace import SpanKind, StatusCode
49+
from pydantic import AnyHttpUrl
4950
from starlette.applications import Starlette
5051
from starlette.middleware import Middleware
5152
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -633,8 +634,11 @@ def streamable_http_app(
633634
# Determine resource metadata URL
634635
resource_metadata_url = None
635636
if auth and auth.resource_server_url:
637+
# RFC 9728: resource identifier must match the URL clients use to access
638+
# the protected resource, including the transport path (e.g. /mcp)
639+
actual_resource_url = AnyHttpUrl(str(auth.resource_server_url).rstrip("/") + streamable_http_path)
636640
# Build compliant metadata URL for WWW-Authenticate header
637-
resource_metadata_url = build_resource_metadata_url(auth.resource_server_url)
641+
resource_metadata_url = build_resource_metadata_url(actual_resource_url)
638642

639643
routes.append(
640644
Route(
@@ -653,9 +657,10 @@ def streamable_http_app(
653657

654658
# Add protected resource metadata endpoint if configured as RS
655659
if auth and auth.resource_server_url: # pragma: no cover
660+
actual_resource_url = AnyHttpUrl(str(auth.resource_server_url).rstrip("/") + streamable_http_path)
656661
routes.extend(
657662
create_protected_resource_routes(
658-
resource_url=auth.resource_server_url,
663+
resource_url=actual_resource_url,
659664
authorization_servers=[auth.issuer_url],
660665
scopes_supported=auth.required_scopes,
661666
)

src/mcp/server/mcpserver/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import anyio
1414
import pydantic_core
15+
from pydantic import AnyHttpUrl
1516
from pydantic.networks import AnyUrl
1617
from pydantic_settings import BaseSettings, SettingsConfigDict
1718
from starlette.applications import Starlette
@@ -987,8 +988,11 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
987988
if self.settings.auth and self.settings.auth.resource_server_url:
988989
from mcp.server.auth.routes import build_resource_metadata_url
989990

991+
# RFC 9728: resource identifier must match the URL clients use to access
992+
# the protected resource, including the transport path (e.g. /sse)
993+
actual_resource_url = AnyHttpUrl(str(self.settings.auth.resource_server_url).rstrip("/") + sse_path)
990994
# Build compliant metadata URL for WWW-Authenticate header
991-
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
995+
resource_metadata_url = build_resource_metadata_url(actual_resource_url)
992996

993997
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
994998
routes.append(
@@ -1028,9 +1032,10 @@ async def sse_endpoint(request: Request) -> Response: # pragma: no cover
10281032
if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover
10291033
from mcp.server.auth.routes import create_protected_resource_routes
10301034

1035+
actual_resource_url = AnyHttpUrl(str(self.settings.auth.resource_server_url).rstrip("/") + sse_path)
10311036
routes.extend(
10321037
create_protected_resource_routes(
1033-
resource_url=self.settings.auth.resource_server_url,
1038+
resource_url=actual_resource_url,
10341039
authorization_servers=[self.settings.auth.issuer_url],
10351040
scopes_supported=self.settings.auth.required_scopes,
10361041
)

tests/server/auth/test_protected_resource.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,68 @@ def test_route_consistency_consistent_paths_for_various_resources(resource_url:
196196
assert url_path == expected_path
197197
assert route_path == expected_path
198198
assert url_path == route_path
199+
200+
201+
# Tests for issue #1264: resource URL must include transport path
202+
203+
204+
@pytest.mark.parametrize(
205+
"resource_server_url,transport_path,expected_resource,expected_metadata_url",
206+
[
207+
(
208+
"http://localhost:8000",
209+
"/mcp",
210+
"http://localhost:8000/mcp",
211+
"http://localhost:8000/.well-known/oauth-protected-resource/mcp",
212+
),
213+
(
214+
"http://localhost:8000/",
215+
"/mcp",
216+
"http://localhost:8000/mcp",
217+
"http://localhost:8000/.well-known/oauth-protected-resource/mcp",
218+
),
219+
(
220+
"https://mcp.example.com",
221+
"/sse",
222+
"https://mcp.example.com/sse",
223+
"https://mcp.example.com/.well-known/oauth-protected-resource/sse",
224+
),
225+
],
226+
)
227+
def test_resource_url_includes_transport_path(
228+
resource_server_url: str,
229+
transport_path: str,
230+
expected_resource: str,
231+
expected_metadata_url: str,
232+
):
233+
"""Transport path must be appended to resource_server_url (issue #1264).
234+
235+
Per RFC 9728, the resource identifier must match the URL clients use to access
236+
the protected resource — e.g. http://localhost:8000/mcp, not http://localhost:8000/.
237+
"""
238+
actual_resource_url = AnyHttpUrl(resource_server_url.rstrip("/") + transport_path)
239+
240+
assert str(actual_resource_url) == expected_resource
241+
242+
metadata_url = build_resource_metadata_url(actual_resource_url)
243+
assert str(metadata_url) == expected_metadata_url
244+
245+
246+
@pytest.mark.anyio
247+
async def test_protected_resource_metadata_contains_transport_path():
248+
"""Metadata endpoint returns resource URL with transport path, not bare server URL."""
249+
resource_url = AnyHttpUrl("http://localhost:8000/mcp")
250+
routes = create_protected_resource_routes(
251+
resource_url=resource_url,
252+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
253+
scopes_supported=["read", "write"],
254+
)
255+
app = Starlette(routes=routes)
256+
257+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://localhost:8000") as client:
258+
response = await client.get("/.well-known/oauth-protected-resource/mcp")
259+
assert response.status_code == 200
260+
data = response.json()
261+
# resource must be the full endpoint URL, not the bare server base
262+
assert data["resource"] == "http://localhost:8000/mcp"
263+
assert data["resource"] != "http://localhost:8000/"

0 commit comments

Comments
 (0)