Skip to content

OAuth Update: Adding the Client Credentials & Token Exchange Grant Types #882

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 82 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
833a105
Add client credentials OAuth grant
SoldierSacha Jun 3, 2025
66c7e67
Merge pull request #1 from sacha-development-stuff/codex/add-support-…
SoldierSacha Jun 3, 2025
813168a
Allow client credentials in dynamic registration
SoldierSacha Jun 3, 2025
dbbc6ce
Merge pull request #2 from sacha-development-stuff/codex/review-and-i…
SoldierSacha Jun 3, 2025
3f2a351
Refactor OAuth helpers
SoldierSacha Jun 3, 2025
62c729d
Merge pull request #3 from sacha-development-stuff/codex/review-imple…
SoldierSacha Jun 3, 2025
5212ce0
clean up code
SoldierSacha Jun 3, 2025
d9c751f
linting
SoldierSacha Jun 4, 2025
7848e68
Fix tests and pyright errors
SoldierSacha Jun 4, 2025
e325b95
Merge pull request
SoldierSacha Jun 4, 2025
3a45cf8
work
SoldierSacha Jun 4, 2025
2132cde
test
SoldierSacha Jun 4, 2025
5c87fb3
test
SoldierSacha Jun 4, 2025
103e201
test
SoldierSacha Jun 4, 2025
ad59c92
Fix async fixture usage in OAuth tests
SoldierSacha Jun 4, 2025
e18e606
Merge pull request #5 from sacha-development-stuff/codex/fix-attribut…
SoldierSacha Jun 4, 2025
49fa6c2
Fix resumption token updates
SoldierSacha Jun 4, 2025
b46aac4
Merge pull request
SoldierSacha Jun 4, 2025
2daea3f
Add OAuth token exchange support
SoldierSacha Jun 10, 2025
94850e7
implement-rfc-8693-token-exchange-in-mcp-sdk
SoldierSacha Jun 10, 2025
627eebd
work
SoldierSacha Jun 10, 2025
beeb244
Merge branch 'main' into main
SoldierSacha Jun 10, 2025
e92e61d
docs: document token-exchange support
SoldierSacha Jun 10, 2025
5976e77
docs
SoldierSacha Jun 10, 2025
bde2448
test: update expectations for token-exchange
SoldierSacha Jun 10, 2025
a98f33f
Merge pull request #9 from sacha-development-stuff/codex/fix-token-ex…
SoldierSacha Jun 10, 2025
b3b0509
Fix pyright token type errors
SoldierSacha Jun 10, 2025
a3edbeb
fix-argument-type-and-abstract-class-errors
SoldierSacha Jun 10, 2025
9b5ef4d
work
SoldierSacha Jun 10, 2025
a0d24ca
Strip whitespace from SSE resumption token
SoldierSacha Jun 10, 2025
7e02ddd
fix-test_streamablehttp_client_resumption-failure
SoldierSacha Jun 10, 2025
d04d17c
Merge branch 'main' into main
SoldierSacha Jun 13, 2025
2d6c062
merge with recent branch
SoldierSacha Jun 13, 2025
02597a2
feat: support combined client creds and token exchange
SoldierSacha Jun 14, 2025
e717dbe
adding token exchange + client credentials as a valid registration gr…
SoldierSacha Jun 14, 2025
1f23248
merge with recent branch
SoldierSacha Jun 14, 2025
ded6b89
Handle closed stream when sending notifications
SoldierSacha Jun 14, 2025
05c46e6
fix-test_streamablehttp_client_resumption-failure
SoldierSacha Jun 14, 2025
bb480a2
Merge branch 'main' into main
SoldierSacha Jun 16, 2025
22c5ef2
Merge branch 'main' into main
SoldierSacha Jun 18, 2025
8fdc5f9
merge with recent branch
SoldierSacha Jun 18, 2025
9f7ae6c
test: stabilize resumption notifications
SoldierSacha Jun 18, 2025
2ab4ad0
Merge pull request #14 from sacha-development-stuff/codex/fix-notific…
SoldierSacha Jun 18, 2025
9e06753
Merge branch 'main' into main
SoldierSacha Jun 23, 2025
b935a6f
Resolve merge conflicts and integrate client credential features
SoldierSacha Jun 24, 2025
c1d0acc
Merge pull request #15 from sacha-development-stuff/codex/fix-merge-c…
SoldierSacha Jun 24, 2025
94cefe3
test: restore missing fixtures
SoldierSacha Jun 24, 2025
482c05e
Merge pull request #16 from sacha-development-stuff/codex/resolve-mer…
SoldierSacha Jun 24, 2025
a41187e
merge with recent branch
SoldierSacha Jun 24, 2025
b7d1aad
merge with recent branch
SoldierSacha Jun 24, 2025
1329ab7
merge with recent branch
SoldierSacha Jun 24, 2025
6d1305d
merge with recent branch
SoldierSacha Jun 24, 2025
f61e57e
merge with recent branch
SoldierSacha Jun 24, 2025
f402804
merge with recent branch
SoldierSacha Jun 25, 2025
b1b34e5
Merge branch 'main' into main
SoldierSacha Jun 25, 2025
4a8294c
docs: document client credentials and introspection
SoldierSacha Jun 25, 2025
75ca216
Merge pull request #17 from sacha-development-stuff/codex/add-client-…
SoldierSacha Jun 25, 2025
0a95397
merge with recent branch
SoldierSacha Jun 25, 2025
30e3c79
Merge branch 'main' into main
SoldierSacha Jun 26, 2025
44bc5a0
Merge branch 'main' into main
SoldierSacha Jun 27, 2025
ceb1e19
Merge branch 'main' into main
SoldierSacha Jun 29, 2025
3bf695c
merge with recent branch
SoldierSacha Jun 29, 2025
a7a7a43
merge with recent branch
SoldierSacha Jun 29, 2025
5e77e28
merge with recent branch
SoldierSacha Jun 29, 2025
9057f02
Merge branch 'main' into main
SoldierSacha Jul 8, 2025
26627c1
merge with recent branch
SoldierSacha Jul 8, 2025
b8c0ba3
merge with recent branch
SoldierSacha Jul 8, 2025
4360875
merge with recent branch
SoldierSacha Jul 8, 2025
1704192
Merge branch 'main' into main
SoldierSacha Jul 8, 2025
4b5eaf2
merge with recent branch
SoldierSacha Jul 8, 2025
ff9d079
merge with recent branch
SoldierSacha Jul 8, 2025
f87b7b6
merge with recent branch
SoldierSacha Jul 8, 2025
6182ac2
Merge branch 'main' into main
SoldierSacha Jul 9, 2025
3e8365b
Merge branch 'main' into main
SoldierSacha Jul 10, 2025
1099d6a
Merge branch 'main' into main
SoldierSacha Jul 11, 2025
5be78af
Merge branch 'main' into main
SoldierSacha Jul 14, 2025
29a6b81
merge with recent branch
SoldierSacha Jul 15, 2025
a81eb50
Merge branch 'main' into main
SoldierSacha Jul 16, 2025
c4900ea
Merge branch 'main' into main
SoldierSacha Jul 17, 2025
add5f08
Merge branch 'main' into main
SoldierSacha Jul 18, 2025
e2b27ff
merge with recent branch
SoldierSacha Jul 18, 2025
b3c6dc4
merge with recent branch
SoldierSacha Jul 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
The Python SDK exposes the entire `mcp` package for use in your own projects.
It includes an OAuth server implementation with support for the RFC 8693
`token_exchange` grant type.

::: mcp
4 changes: 4 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
This is the MCP Server implementation in Python.

It only contains the [API Reference](api.md) for the time being.

The built-in OAuth server supports the RFC 8693 `token_exchange` grant type,
allowing clients to exchange user tokens from external providers for MCP
access tokens.
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,52 @@ async def exchange_authorization_code(
scope=" ".join(authorization_code.scopes),
)

async def exchange_client_credentials(self, client: OAuthClientInformationFull, scopes: list[str]) -> OAuthToken:
"""Exchange client credentials for an MCP access token."""
mcp_token = f"mcp_{secrets.token_hex(32)}"
self.tokens[mcp_token] = AccessToken(
token=mcp_token,
client_id=client.client_id,
scopes=scopes,
expires_at=int(time.time()) + 3600,
)
return OAuthToken(
access_token=mcp_token,
token_type="Bearer",
expires_in=3600,
scope=" ".join(scopes),
)

async def exchange_token(
self,
client: OAuthClientInformationFull,
subject_token: str,
subject_token_type: str,
actor_token: str | None,
actor_token_type: str | None,
scope: list[str] | None,
audience: str | None,
resource: str | None,
) -> OAuthToken:
"""Exchange an external token for an MCP access token."""
if not subject_token:
raise ValueError("Invalid subject token")

mcp_token = f"mcp_{secrets.token_hex(32)}"
self.tokens[mcp_token] = AccessToken(
token=mcp_token,
client_id=client.client_id,
scopes=scope or [self.settings.mcp_scope],
expires_at=int(time.time()) + 3600,
resource=resource,
)
return OAuthToken(
access_token=mcp_token,
token_type="Bearer",
expires_in=3600,
scope=" ".join(scope or [self.settings.mcp_scope]),
)

async def load_access_token(self, token: str) -> AccessToken | None:
"""Load and validate an access token."""
access_token = self.tokens.get(token)
Expand Down
290 changes: 285 additions & 5 deletions src/mcp/client/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,15 @@ def update_token_expiry(self, token: OAuthToken) -> None:
self.token_expiry_time = None

def is_token_valid(self) -> bool:
"""Check if current token is valid."""
"""Check if the current token is valid."""
return bool(
self.current_tokens
and self.current_tokens.access_token
and (not self.token_expiry_time or time.time() <= self.token_expiry_time)
)

def can_refresh_token(self) -> bool:
"""Check if token can be refreshed."""
"""Check if the token can be refreshed."""
return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info)

def clear_tokens(self) -> None:
Expand Down Expand Up @@ -546,6 +546,286 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
logger.exception("OAuth flow error")
raise

# Retry with new tokens
self._add_auth_header(request)
yield request
# Retry with new tokens
self._add_auth_header(request)
yield request


class ClientCredentialsProvider(httpx.Auth):
"""HTTPX auth using the OAuth2 client credentials grant."""

def __init__(
self,
server_url: str,
client_metadata: OAuthClientMetadata,
storage: TokenStorage,
resource: str | None = None,
timeout: float = 300.0,
):
self.server_url = server_url
self.client_metadata = client_metadata
self.storage = storage
self.timeout = timeout
self.resource = resource or resource_url_from_server_url(server_url)

self._current_tokens: OAuthToken | None = None
self._metadata: OAuthMetadata | None = None
self._client_info: OAuthClientInformationFull | None = None
self._token_expiry_time: float | None = None

self._token_lock = anyio.Lock()

def _get_authorization_base_url(self, server_url: str) -> str:
"""Return base authorization server URL without path."""
parsed = urlparse(server_url)
return f"{parsed.scheme}://{parsed.netloc}"

async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | None:
"""Discover OAuth server metadata for client credentials."""
auth_base_url = self._get_authorization_base_url(server_url)
url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server")
headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION}

async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers=headers)
if response.status_code == 404:
return None
response.raise_for_status()
return OAuthMetadata.model_validate(response.json())
except Exception:
try:
response = await client.get(url)
if response.status_code == 404:
return None
response.raise_for_status()
return OAuthMetadata.model_validate(response.json())
except Exception:
logger.exception("Failed to discover OAuth metadata")
return None

async def _register_oauth_client(
self,
server_url: str,
client_metadata: OAuthClientMetadata,
metadata: OAuthMetadata | None = None,
) -> OAuthClientInformationFull:
if not metadata:
metadata = await self._discover_oauth_metadata(server_url)

if metadata and metadata.registration_endpoint:
registration_url = str(metadata.registration_endpoint)
else:
auth_base_url = self._get_authorization_base_url(server_url)
registration_url = urljoin(auth_base_url, "/register")

if client_metadata.scope is None and metadata and metadata.scopes_supported is not None:
client_metadata.scope = " ".join(metadata.scopes_supported)

registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)

async with httpx.AsyncClient() as client:
response = await client.post(
registration_url,
json=registration_data,
headers={"Content-Type": "application/json"},
)

if response.status_code not in (200, 201):
raise httpx.HTTPStatusError(
f"Registration failed: {response.status_code}",
request=response.request,
response=response,
)

return OAuthClientInformationFull.model_validate(response.json())

def _has_valid_token(self) -> bool:
if not self._current_tokens or not self._current_tokens.access_token:
return False

if self._token_expiry_time and time.time() > self._token_expiry_time:
return False
return True

async def _validate_token_scopes(self, token_response: OAuthToken) -> None:
if not token_response.scope:
return

requested_scopes: set[str] = set()
if self.client_metadata.scope:
requested_scopes = set(self.client_metadata.scope.split())
returned_scopes = set(token_response.scope.split())
unauthorized_scopes = returned_scopes - requested_scopes
if unauthorized_scopes:
raise Exception(f"Server granted unauthorized scopes: {unauthorized_scopes}.")
else:
granted = set(token_response.scope.split())
logger.debug(
"No explicit scopes requested, accepting server-granted scopes: %s",
granted,
)

async def initialize(self) -> None:
self._current_tokens = await self.storage.get_tokens()
self._client_info = await self.storage.get_client_info()

async def _get_or_register_client(self) -> OAuthClientInformationFull:
if not self._client_info:
self._client_info = await self._register_oauth_client(self.server_url, self.client_metadata, self._metadata)
await self.storage.set_client_info(self._client_info)
return self._client_info

async def _request_token(self) -> None:
if not self._metadata:
self._metadata = await self._discover_oauth_metadata(self.server_url)

client_info = await self._get_or_register_client()

if self._metadata and self._metadata.token_endpoint:
token_url = str(self._metadata.token_endpoint)
else:
auth_base_url = self._get_authorization_base_url(self.server_url)
token_url = urljoin(auth_base_url, "/token")

token_data = {
"grant_type": "client_credentials",
"client_id": client_info.client_id,
"resource": self.resource,
}

if client_info.client_secret:
token_data["client_secret"] = client_info.client_secret

if self.client_metadata.scope:
token_data["scope"] = self.client_metadata.scope

async with httpx.AsyncClient() as client:
response = await client.post(
token_url,
data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)

if response.status_code != 200:
raise Exception(f"Token request failed: {response.status_code} {response.text}")

token_response = OAuthToken.model_validate(response.json())
await self._validate_token_scopes(token_response)

if token_response.expires_in:
self._token_expiry_time = time.time() + token_response.expires_in
else:
self._token_expiry_time = None

await self.storage.set_tokens(token_response)
self._current_tokens = token_response

async def ensure_token(self) -> None:
async with self._token_lock:
if self._has_valid_token():
return
await self._request_token()

async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
if not self._has_valid_token():
await self.initialize()
await self.ensure_token()

if self._current_tokens and self._current_tokens.access_token:
request.headers["Authorization"] = f"Bearer {self._current_tokens.access_token}"

response = yield request

if response.status_code == 401:
self._current_tokens = None


class TokenExchangeProvider(ClientCredentialsProvider):
"""OAuth2 token exchange based on RFC 8693."""

def __init__(
self,
server_url: str,
client_metadata: OAuthClientMetadata,
storage: TokenStorage,
subject_token_supplier: Callable[[], Awaitable[str]],
subject_token_type: str = "access_token",
actor_token_supplier: Callable[[], Awaitable[str]] | None = None,
actor_token_type: str | None = None,
audience: str | None = None,
resource: str | None = None,
timeout: float = 300.0,
):
"""Create a new token exchange provider.

Parameters are forwarded to ClientCredentialsProvider for
client authentication. The resource parameter binds issued tokens to
the target resource, as defined by RFC 8707.
"""

super().__init__(server_url, client_metadata, storage, resource, timeout)
self.subject_token_supplier = subject_token_supplier
self.subject_token_type = subject_token_type
self.actor_token_supplier = actor_token_supplier
self.actor_token_type = actor_token_type
self.audience = audience

async def _request_token(self) -> None:
if not self._metadata:
self._metadata = await self._discover_oauth_metadata(self.server_url)

client_info = await self._get_or_register_client()

if self._metadata and self._metadata.token_endpoint:
token_url = str(self._metadata.token_endpoint)
else:
auth_base_url = self._get_authorization_base_url(self.server_url)
token_url = urljoin(auth_base_url, "/token")

subject_token = await self.subject_token_supplier()
actor_token = await self.actor_token_supplier() if self.actor_token_supplier else None

token_data = {
"grant_type": "token_exchange",
"client_id": client_info.client_id,
"subject_token": subject_token,
"subject_token_type": self.subject_token_type,
}

if client_info.client_secret:
token_data["client_secret"] = client_info.client_secret

if actor_token:
token_data["actor_token"] = actor_token
if self.actor_token_type:
token_data["actor_token_type"] = self.actor_token_type
if self.audience:
token_data["audience"] = self.audience
if self.resource:
token_data["resource"] = self.resource
if self.client_metadata.scope:
token_data["scope"] = self.client_metadata.scope

async with httpx.AsyncClient() as client:
response = await client.post(
token_url,
data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)

if response.status_code != 200:
raise Exception(f"Token request failed: {response.status_code} {response.text}")

token_response = OAuthToken.model_validate(response.json())
await self._validate_token_scopes(token_response)

if token_response.expires_in:
self._token_expiry_time = time.time() + token_response.expires_in
else:
self._token_expiry_time = None

await self.storage.set_tokens(token_response)
self._current_tokens = token_response
10 changes: 6 additions & 4 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,11 @@ async def _handle_sse_event(
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)

# Call resumption token callback if we have an ID
if sse.id and resumption_callback:
await resumption_callback(sse.id)
# Call resumption token callback if we have an ID. Only update
# the resumption token on notifications to avoid overwriting it
# with the token from the final response.
if sse.id and resumption_callback and not isinstance(message.root, JSONRPCResponse | JSONRPCError):
await resumption_callback(sse.id.strip())

# If this is a response or error return True indicating completion
# Otherwise, return False to continue listening
Expand Down Expand Up @@ -221,7 +223,7 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
"""Handle a resumption request using GET with SSE."""
headers = self._prepare_request_headers(ctx.headers)
if ctx.metadata and ctx.metadata.resumption_token:
headers[LAST_EVENT_ID] = ctx.metadata.resumption_token
headers[LAST_EVENT_ID] = ctx.metadata.resumption_token.strip()
else:
raise ResumptionError("Resumption request requires a resumption token")

Expand Down
Loading
Loading