RFC 8707 Resource Indicators Implementation (#991)

This commit is contained in:
Inna Harper 2025-06-23 17:10:20 +01:00 • committed by GitHub
commit 679b22970e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 284 additions and 5 deletions

View file

@ -27,6 +27,7 @@ from mcp.shared.auth import (
OAuthToken,
ProtectedResourceMetadata,
)
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
from mcp.types import LATEST_PROTOCOL_VERSION
logger = logging.getLogger(__name__)
@ -134,6 +135,21 @@ class OAuthContext:
self.current_tokens = None
self.token_expiry_time = None
def get_resource_url(self) -> str:
"""Get resource URL for RFC 8707.
Uses PRM resource if it's a valid parent, otherwise uses canonical server URL.
"""
resource = resource_url_from_server_url(self.server_url)
# If PRM provides a resource that's a valid parent, use it
if self.protected_resource_metadata and self.protected_resource_metadata.resource:
prm_resource = str(self.protected_resource_metadata.resource)
if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource):
resource = prm_resource
return resource
class OAuthClientProvider(httpx.Auth):
"""
@ -256,6 +272,7 @@ class OAuthClientProvider(httpx.Auth):
"state": state,
"code_challenge": pkce_params.code_challenge,
"code_challenge_method": "S256",
"resource": self.context.get_resource_url(), # RFC 8707
}
if self.context.client_metadata.scope:
@ -293,6 +310,7 @@ class OAuthClientProvider(httpx.Auth):
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
"client_id": self.context.client_info.client_id,
"code_verifier": code_verifier,
"resource": self.context.get_resource_url(), # RFC 8707
}
if self.context.client_info.client_secret:
@ -343,6 +361,7 @@ class OAuthClientProvider(httpx.Auth):
"grant_type": "refresh_token",
"refresh_token": self.context.current_tokens.refresh_token,
"client_id": self.context.client_info.client_id,
"resource": self.context.get_resource_url(), # RFC 8707
}
if self.context.client_info.client_secret:

View file

@ -35,6 +35,10 @@ class AuthorizationRequest(BaseModel):
None,
description="Optional scope; if specified, should be " "a space-separated list of scope strings",
)
resource: str | None = Field(
None,
description="RFC 8707 resource indicator - the MCP server this token will be used with",
)
class AuthorizationErrorResponse(BaseModel):
@ -197,6 +201,7 @@ class AuthorizationHandler:
code_challenge=auth_request.code_challenge,
redirect_uri=redirect_uri,
redirect_uri_provided_explicitly=auth_request.redirect_uri is not None,
resource=auth_request.resource, # RFC 8707
)
try:

View file

@ -24,6 +24,8 @@ class AuthorizationCodeRequest(BaseModel):
client_secret: str | None = None
# See https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
code_verifier: str = Field(..., description="PKCE code verifier")
# RFC 8707 resource indicator
resource: str | None = Field(None, description="Resource indicator for the token")
class RefreshTokenRequest(BaseModel):
@ -34,6 +36,8 @@ class RefreshTokenRequest(BaseModel):
client_id: str
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
client_secret: str | None = None
# RFC 8707 resource indicator
resource: str | None = Field(None, description="Resource indicator for the token")
class TokenRequest(

View file

@ -13,6 +13,7 @@ class AuthorizationParams(BaseModel):
code_challenge: str
redirect_uri: AnyUrl
redirect_uri_provided_explicitly: bool
resource: str | None = None # RFC 8707 resource indicator
class AuthorizationCode(BaseModel):
@ -23,6 +24,7 @@ class AuthorizationCode(BaseModel):
code_challenge: str
redirect_uri: AnyUrl
redirect_uri_provided_explicitly: bool
resource: str | None = None # RFC 8707 resource indicator
class RefreshToken(BaseModel):
@ -37,6 +39,7 @@ class AccessToken(BaseModel):
client_id: str
scopes: list[str]
expires_at: int | None = None
resource: str | None = None # RFC 8707 resource indicator
RegistrationErrorCode = Literal[

View file

@ -0,0 +1,69 @@
"""Utilities for OAuth 2.0 Resource Indicators (RFC 8707)."""
from urllib.parse import urlparse, urlsplit, urlunsplit
from pydantic import AnyUrl, HttpUrl
def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str:
"""Convert server URL to canonical resource URL per RFC 8707.
RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component".
Returns absolute URI with lowercase scheme/host for canonical form.
Args:
url: Server URL to convert
Returns:
Canonical resource URL string
"""
# Convert to string if needed
url_str = str(url)
# Parse the URL and remove fragment, create canonical form
parsed = urlsplit(url_str)
canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower(), fragment=""))
return canonical
def check_resource_allowed(requested_resource: str, configured_resource: str) -> bool:
"""Check if a requested resource URL matches a configured resource URL.
A requested resource matches if it has the same scheme, domain, port,
and its path starts with the configured resource's path. This allows
hierarchical matching where a token for a parent resource can be used
for child resources.
Args:
requested_resource: The resource URL being requested
configured_resource: The resource URL that has been configured
Returns:
True if the requested resource matches the configured resource
"""
# Parse both URLs
requested = urlparse(requested_resource)
configured = urlparse(configured_resource)
# Compare scheme, host, and port (origin)
if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower():
return False
# Handle cases like requested=/foo and configured=/foo/
requested_path = requested.path
configured_path = configured.path
# If requested path is shorter, it cannot be a child
if len(requested_path) < len(configured_path):
return False
# Check if the requested path starts with the configured path
# Ensure both paths end with / for proper comparison
# This ensures that paths like "/api123" don't incorrectly match "/api"
if not requested_path.endswith("/"):
requested_path += "/"
if not configured_path.endswith("/"):
configured_path += "/"
return requested_path.startswith(configured_path)