Skip to content

Commit 41f3bc3

Browse files
authored
Make "resource" optional on earlier protocols (#1017)
Co-authored-by: Andres March <>
1 parent 4d45bb8 commit 41f3bc3

File tree

2 files changed

+117
-3
lines changed

2 files changed

+117
-3
lines changed

src/mcp/client/auth.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class OAuthContext:
9595
protected_resource_metadata: ProtectedResourceMetadata | None = None
9696
oauth_metadata: OAuthMetadata | None = None
9797
auth_server_url: str | None = None
98+
protocol_version: str | None = None
9899

99100
# Client registration
100101
client_info: OAuthClientInformationFull | None = None
@@ -154,6 +155,25 @@ def get_resource_url(self) -> str:
154155

155156
return resource
156157

158+
def should_include_resource_param(self, protocol_version: str | None = None) -> bool:
159+
"""Determine if the resource parameter should be included in OAuth requests.
160+
161+
Returns True if:
162+
- Protected resource metadata is available, OR
163+
- MCP-Protocol-Version header is 2025-06-18 or later
164+
"""
165+
# If we have protected resource metadata, include the resource param
166+
if self.protected_resource_metadata is not None:
167+
return True
168+
169+
# If no protocol version provided, don't include resource param
170+
if not protocol_version:
171+
return False
172+
173+
# Check if protocol version is 2025-06-18 or later
174+
# Version format is YYYY-MM-DD, so string comparison works
175+
return protocol_version >= "2025-06-18"
176+
157177

158178
class OAuthClientProvider(httpx.Auth):
159179
"""
@@ -320,9 +340,12 @@ async def _perform_authorization(self) -> tuple[str, str]:
320340
"state": state,
321341
"code_challenge": pkce_params.code_challenge,
322342
"code_challenge_method": "S256",
323-
"resource": self.context.get_resource_url(), # RFC 8707
324343
}
325344

345+
# Only include resource param if conditions are met
346+
if self.context.should_include_resource_param(self.context.protocol_version):
347+
auth_params["resource"] = self.context.get_resource_url() # RFC 8707
348+
326349
if self.context.client_metadata.scope:
327350
auth_params["scope"] = self.context.client_metadata.scope
328351

@@ -358,9 +381,12 @@ async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Req
358381
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
359382
"client_id": self.context.client_info.client_id,
360383
"code_verifier": code_verifier,
361-
"resource": self.context.get_resource_url(), # RFC 8707
362384
}
363385

386+
# Only include resource param if conditions are met
387+
if self.context.should_include_resource_param(self.context.protocol_version):
388+
token_data["resource"] = self.context.get_resource_url() # RFC 8707
389+
364390
if self.context.client_info.client_secret:
365391
token_data["client_secret"] = self.context.client_info.client_secret
366392

@@ -409,9 +435,12 @@ async def _refresh_token(self) -> httpx.Request:
409435
"grant_type": "refresh_token",
410436
"refresh_token": self.context.current_tokens.refresh_token,
411437
"client_id": self.context.client_info.client_id,
412-
"resource": self.context.get_resource_url(), # RFC 8707
413438
}
414439

440+
# Only include resource param if conditions are met
441+
if self.context.should_include_resource_param(self.context.protocol_version):
442+
refresh_data["resource"] = self.context.get_resource_url() # RFC 8707
443+
415444
if self.context.client_info.client_secret:
416445
refresh_data["client_secret"] = self.context.client_info.client_secret
417446

@@ -457,6 +486,9 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
457486
if not self._initialized:
458487
await self._initialize()
459488

489+
# Capture protocol version from request headers
490+
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
491+
460492
# Perform OAuth flow if not authenticated
461493
if not self.context.is_token_valid():
462494
try:

tests/client/test_auth.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
OAuthClientInformationFull,
1414
OAuthClientMetadata,
1515
OAuthToken,
16+
ProtectedResourceMetadata,
1617
)
1718

1819

@@ -434,6 +435,87 @@ async def test_refresh_token_request(self, oauth_provider, valid_tokens):
434435
assert "client_secret=test_secret" in content
435436

436437

438+
class TestProtectedResourceMetadata:
439+
"""Test protected resource handling."""
440+
441+
@pytest.mark.anyio
442+
async def test_resource_param_included_with_recent_protocol_version(self, oauth_provider: OAuthClientProvider):
443+
"""Test resource parameter is included for protocol version >= 2025-06-18."""
444+
# Set protocol version to 2025-06-18
445+
oauth_provider.context.protocol_version = "2025-06-18"
446+
oauth_provider.context.client_info = OAuthClientInformationFull(
447+
client_id="test_client",
448+
client_secret="test_secret",
449+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
450+
)
451+
452+
# Test in token exchange
453+
request = await oauth_provider._exchange_token("test_code", "test_verifier")
454+
content = request.content.decode()
455+
assert "resource=" in content
456+
# Check URL-encoded resource parameter
457+
from urllib.parse import quote
458+
459+
expected_resource = quote(oauth_provider.context.get_resource_url(), safe="")
460+
assert f"resource={expected_resource}" in content
461+
462+
# Test in refresh token
463+
oauth_provider.context.current_tokens = OAuthToken(
464+
access_token="test_access",
465+
token_type="Bearer",
466+
refresh_token="test_refresh",
467+
)
468+
refresh_request = await oauth_provider._refresh_token()
469+
refresh_content = refresh_request.content.decode()
470+
assert "resource=" in refresh_content
471+
472+
@pytest.mark.anyio
473+
async def test_resource_param_excluded_with_old_protocol_version(self, oauth_provider: OAuthClientProvider):
474+
"""Test resource parameter is excluded for protocol version < 2025-06-18."""
475+
# Set protocol version to older version
476+
oauth_provider.context.protocol_version = "2025-03-26"
477+
oauth_provider.context.client_info = OAuthClientInformationFull(
478+
client_id="test_client",
479+
client_secret="test_secret",
480+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
481+
)
482+
483+
# Test in token exchange
484+
request = await oauth_provider._exchange_token("test_code", "test_verifier")
485+
content = request.content.decode()
486+
assert "resource=" not in content
487+
488+
# Test in refresh token
489+
oauth_provider.context.current_tokens = OAuthToken(
490+
access_token="test_access",
491+
token_type="Bearer",
492+
refresh_token="test_refresh",
493+
)
494+
refresh_request = await oauth_provider._refresh_token()
495+
refresh_content = refresh_request.content.decode()
496+
assert "resource=" not in refresh_content
497+
498+
@pytest.mark.anyio
499+
async def test_resource_param_included_with_protected_resource_metadata(self, oauth_provider: OAuthClientProvider):
500+
"""Test resource parameter is always included when protected resource metadata exists."""
501+
# Set old protocol version but with protected resource metadata
502+
oauth_provider.context.protocol_version = "2025-03-26"
503+
oauth_provider.context.protected_resource_metadata = ProtectedResourceMetadata(
504+
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
505+
authorization_servers=[AnyHttpUrl("https://api.example.com")],
506+
)
507+
oauth_provider.context.client_info = OAuthClientInformationFull(
508+
client_id="test_client",
509+
client_secret="test_secret",
510+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
511+
)
512+
513+
# Test in token exchange
514+
request = await oauth_provider._exchange_token("test_code", "test_verifier")
515+
content = request.content.decode()
516+
assert "resource=" in content
517+
518+
437519
class TestAuthFlow:
438520
"""Test the auth flow in httpx."""
439521

0 commit comments

Comments
 (0)