Skip to content

Commit 15834da

Browse files
committed
enhance: enhance resource metadata discovery URLs for mounted starlette servers
1 parent 0dedbd9 commit 15834da

File tree

2 files changed

+57
-24
lines changed

2 files changed

+57
-24
lines changed

src/mcp/client/auth/utils.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, s
7171
Per SEP-985, the client MUST:
7272
1. Try resource_metadata from WWW-Authenticate header (if present)
7373
2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path}
74+
or /{mount path}/.well-known/oauth-protected-resource for starlete mounted servers
7475
3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource
7576
7677
Args:
@@ -90,9 +91,17 @@ def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, s
9091
parsed = urlparse(server_url)
9192
base_url = f"{parsed.scheme}://{parsed.netloc}"
9293

93-
# Priority 2: Path-based well-known URI (if server has a path component)
94+
# Priority 2: Path-based well-known URI (if server has a path component or mounted app)
9495
if parsed.path and parsed.path != "/":
9596
path_based_url = urljoin(base_url, f"/.well-known/oauth-protected-resource{parsed.path}")
97+
# Mounted app base path at 0 index
98+
root_path_based_url = urljoin(
99+
base_url,
100+
f"""/{
101+
parsed.path.strip("/").rpartition("/")[0] or parsed.path.strip("/")
102+
}/.well-known/oauth-protected-resource""",
103+
)
104+
urls.append(root_path_based_url)
96105
urls.append(path_based_url)
97106

98107
# Priority 3: Root-based well-known URI

tests/client/test_auth.py

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,20 +1299,27 @@ async def callback_handler() -> tuple[str, str | None]:
12991299

13001300
# Should try path-based PRM first
13011301
prm_request_1 = await auth_flow.asend(response)
1302-
assert str(prm_request_1.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource/sse"
1302+
assert str(prm_request_1.url) == "https://mcp.linear.app/sse/.well-known/oauth-protected-resource"
13031303

13041304
# PRM returns 404
13051305
prm_response_1 = httpx.Response(404, request=prm_request_1)
13061306

1307-
# Should try root-based PRM
1307+
# Should try path-based PRM first
13081308
prm_request_2 = await auth_flow.asend(prm_response_1)
1309-
assert str(prm_request_2.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource"
1309+
assert str(prm_request_2.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource/sse"
13101310

1311-
# PRM returns 404 again - all PRM URLs failed
1311+
# PRM returns 404
13121312
prm_response_2 = httpx.Response(404, request=prm_request_2)
13131313

1314+
# Should try root-based PRM
1315+
prm_request_3 = await auth_flow.asend(prm_response_2)
1316+
assert str(prm_request_3.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource"
1317+
1318+
# PRM returns 404 again - all PRM URLs failed
1319+
prm_response_3 = httpx.Response(404, request=prm_request_3)
1320+
13141321
# Should fall back to root OAuth discovery (March 2025 spec behavior)
1315-
oauth_metadata_request = await auth_flow.asend(prm_response_2)
1322+
oauth_metadata_request = await auth_flow.asend(prm_response_3)
13161323
assert str(oauth_metadata_request.url) == "https://mcp.linear.app/.well-known/oauth-authorization-server"
13171324
assert oauth_metadata_request.method == "GET"
13181325

@@ -1407,20 +1414,27 @@ async def callback_handler() -> tuple[str, str | None]:
14071414

14081415
# Try path-based fallback
14091416
prm_request_2 = await auth_flow.asend(prm_response_1)
1410-
assert str(prm_request_2.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1417+
assert str(prm_request_2.url) == "https://api.example.com/v1/.well-known/oauth-protected-resource"
14111418

14121419
# Returns 404
14131420
prm_response_2 = httpx.Response(404, request=prm_request_2)
14141421

1415-
# Try root fallback
1422+
# Try path-based fallback
14161423
prm_request_3 = await auth_flow.asend(prm_response_2)
1417-
assert str(prm_request_3.url) == "https://api.example.com/.well-known/oauth-protected-resource"
1424+
assert str(prm_request_3.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
14181425

1419-
# Also returns 404 - all PRM URLs failed
1426+
# Returns 404
14201427
prm_response_3 = httpx.Response(404, request=prm_request_3)
14211428

1429+
# Try root fallback
1430+
prm_request_4 = await auth_flow.asend(prm_response_3)
1431+
assert str(prm_request_4.url) == "https://api.example.com/.well-known/oauth-protected-resource"
1432+
1433+
# Also returns 404 - all PRM URLs failed
1434+
prm_response_4 = httpx.Response(404, request=prm_request_4)
1435+
14221436
# Should fall back to root OAuth discovery
1423-
oauth_metadata_request = await auth_flow.asend(prm_response_3)
1437+
oauth_metadata_request = await auth_flow.asend(prm_response_4)
14241438
assert str(oauth_metadata_request.url) == "https://api.example.com/.well-known/oauth-authorization-server"
14251439

14261440
# Complete the flow
@@ -1491,9 +1505,10 @@ async def callback_handler() -> tuple[str, str | None]:
14911505
)
14921506

14931507
# Should have path-based URL first, then root-based URL
1494-
assert len(discovery_urls) == 2
1495-
assert discovery_urls[0] == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1496-
assert discovery_urls[1] == "https://api.example.com/.well-known/oauth-protected-resource"
1508+
assert len(discovery_urls) == 3
1509+
assert discovery_urls[0] == "https://api.example.com/v1/.well-known/oauth-protected-resource"
1510+
assert discovery_urls[1] == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1511+
assert discovery_urls[2] == "https://api.example.com/.well-known/oauth-protected-resource"
14971512

14981513
@pytest.mark.anyio
14991514
async def test_root_based_fallback_after_path_based_404(
@@ -1539,33 +1554,41 @@ async def callback_handler() -> tuple[str, str | None]:
15391554
# Send a 401 response without WWW-Authenticate header
15401555
response = httpx.Response(401, headers={}, request=test_request)
15411556

1542-
# Next request should be to discover protected resource metadata (path-based)
1557+
# Next request should be to discover protected resource metadata (mounted path-based)
15431558
discovery_request_1 = await auth_flow.asend(response)
1544-
assert str(discovery_request_1.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1559+
assert str(discovery_request_1.url) == "https://api.example.com/v1/.well-known/oauth-protected-resource"
15451560
assert discovery_request_1.method == "GET"
15461561

15471562
# Send 404 response for path-based discovery
15481563
discovery_response_1 = httpx.Response(404, request=discovery_request_1)
15491564

1550-
# Next request should be to root-based well-known URI
1565+
# Next request should be to discover protected resource metadata (path-based)
15511566
discovery_request_2 = await auth_flow.asend(discovery_response_1)
1552-
assert str(discovery_request_2.url) == "https://api.example.com/.well-known/oauth-protected-resource"
1567+
assert str(discovery_request_2.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
15531568
assert discovery_request_2.method == "GET"
15541569

1570+
# Send 404 response for path-based discovery
1571+
discovery_response_2 = httpx.Response(404, request=discovery_request_2)
1572+
1573+
# Next request should be to root-based well-known URI
1574+
discovery_request_3 = await auth_flow.asend(discovery_response_2)
1575+
assert str(discovery_request_3.url) == "https://api.example.com/.well-known/oauth-protected-resource"
1576+
assert discovery_request_3.method == "GET"
1577+
15551578
# Send successful discovery response
1556-
discovery_response_2 = httpx.Response(
1579+
discovery_response_3 = httpx.Response(
15571580
200,
15581581
content=(
15591582
b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}'
15601583
),
1561-
request=discovery_request_2,
1584+
request=discovery_request_3,
15621585
)
15631586

15641587
# Mock the rest of the OAuth flow
15651588
provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
15661589

15671590
# Next should be OAuth metadata discovery
1568-
oauth_metadata_request = await auth_flow.asend(discovery_response_2)
1591+
oauth_metadata_request = await auth_flow.asend(discovery_response_3)
15691592
assert oauth_metadata_request.method == "GET"
15701593

15711594
# Complete the flow
@@ -1631,10 +1654,11 @@ async def callback_handler() -> tuple[str, str | None]:
16311654
)
16321655

16331656
# Should have WWW-Authenticate URL first, then fallback URLs
1634-
assert len(discovery_urls) == 3
1657+
assert len(discovery_urls) == 4
16351658
assert discovery_urls[0] == "https://custom.example.com/.well-known/oauth-protected-resource"
1636-
assert discovery_urls[1] == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1637-
assert discovery_urls[2] == "https://api.example.com/.well-known/oauth-protected-resource"
1659+
assert discovery_urls[1] == "https://api.example.com/v1/.well-known/oauth-protected-resource"
1660+
assert discovery_urls[2] == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1661+
assert discovery_urls[3] == "https://api.example.com/.well-known/oauth-protected-resource"
16381662

16391663

16401664
class TestWWWAuthenticate:

0 commit comments

Comments
 (0)