Skip to content

Commit e8df714

Browse files
committed
Support RFC 8693 token exchange for enterprise IdP flows (SEP-990)
Add the OAuth 2.0 token-exchange grant (urn:ietf:params:oauth:grant-type:token-exchange), the wire mechanism behind SEP-990. A client exchanges an enterprise IdP-issued ID-JAG for an MCP access token at the authorization server's token endpoint. Client: TokenExchangeOAuthProvider posts the exchange, sourcing the ID-JAG from an async subject_token_provider(audience) callback. Server: OAuthAuthorizationServerProvider.exchange_token validates the subject token and issues the access token; gated by AuthSettings.token_exchange_enabled, which also advertises the grant (and the public-client 'none' auth method) in metadata. TokenError gains invalid_target; TokenExchangeToken carries RFC 8693 issued_token_type.
1 parent 603342f commit e8df714

15 files changed

Lines changed: 1222 additions & 11 deletions

File tree

docs/migration.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,35 @@ client_metadata = OAuthClientMetadata(
14311431

14321432
Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter.
14331433

1434+
### Token exchange for enterprise IdP flows (SEP-990)
1435+
1436+
The SDK now supports the RFC 8693 token-exchange grant (`urn:ietf:params:oauth:grant-type:token-exchange`), the wire mechanism behind SEP-990's enterprise identity-provider policy controls. A client presents a security token issued by an enterprise IdP - the Identity Assertion Authorization Grant (ID-JAG) - to the MCP authorization server, which validates it and returns an MCP access token. This is additive and opt-in on both sides; existing flows are unchanged.
1437+
1438+
On the client, `TokenExchangeOAuthProvider` (in `mcp.client.auth.extensions.token_exchange`) is an `httpx.Auth` that posts the exchange request, mirroring the `client_credentials` extension. The ID-JAG is supplied lazily through an async `subject_token_provider(audience)` callback - the SDK does not implement IdP login or the first exchange against the IdP, which are deployment-specific:
1439+
1440+
```python
1441+
from mcp.client.auth.extensions.token_exchange import TokenExchangeOAuthProvider
1442+
1443+
1444+
async def fetch_id_jag(audience: str) -> str:
1445+
# `audience` is the authorization server's issuer; the ID-JAG must carry it as `aud`.
1446+
return await my_idp.exchange_id_token_for_id_jag(audience=audience)
1447+
1448+
1449+
provider = TokenExchangeOAuthProvider(
1450+
server_url="https://mcp.example.com/mcp",
1451+
storage=my_token_storage,
1452+
client_id="enterprise-mcp-client",
1453+
subject_token_provider=fetch_id_jag,
1454+
)
1455+
```
1456+
1457+
The client defaults to a public client (`token_endpoint_auth_method="none"`); pass a `client_secret` (optionally with `token_endpoint_auth_method="client_secret_basic"`) for a confidential client. `requested_token_type` defaults to `urn:ietf:params:oauth:token-type:access_token` since the SEP-990 output is an MCP access token; pass `None` to omit it.
1458+
1459+
On the authorization server, set `AuthSettings(token_exchange_enabled=True)` (or pass `token_exchange_enabled=True` to `create_auth_routes`) and implement `exchange_token` on your `OAuthAuthorizationServerProvider`. The method receives a `TokenExchangeParams` (subject token, token types, scopes, resource, audience), validates the subject token, decides which scopes to grant, and returns an `OAuthToken` (or a `TokenExchangeToken` to set RFC 8693's `issued_token_type`, which the handler otherwise defaults to the access-token type). The flag gates both metadata advertisement and the token endpoint: when it is off, `/token` rejects the grant with `unsupported_grant_type` even if the provider implements `exchange_token`. When on, the authorization server also advertises the `none` token-endpoint auth method for the common public-client case. The base `exchange_token` rejects every request with `unsupported_grant_type`, and `TokenError` now includes RFC 8693's `invalid_target` for unknown `resource`/`audience` targets.
1460+
1461+
The provider owns scope decisions: requested scopes must never be granted verbatim, since a valid ID-JAG could otherwise be exchanged for arbitrary access. Derive the granted scopes from the validated ID-JAG and policy and narrow the request against them (see `examples/snippets/servers/token_exchange_server.py`). Note also that the bundled Dynamic Client Registration handler still requires `authorization_code` in `grant_types`, so a token-exchange client must register both grants (or be pre-registered).
1462+
14341463
### 2025-11-25 and 2026-07-28 protocol fields modeled
14351464

14361465
`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Client side of SEP-990 (enterprise IdP policy controls).
2+
3+
`TokenExchangeOAuthProvider` exchanges a subject token issued by the enterprise IdP - the
4+
Identity Assertion Authorization Grant (ID-JAG) - for an MCP access token, using the RFC 8693
5+
token-exchange grant at the MCP authorization server's token endpoint. No browser redirect or
6+
dynamic client registration is involved.
7+
8+
Obtaining the ID-JAG (logging into the IdP and performing the first exchange against it) is
9+
deployment-specific and out of scope for the SDK; supply it through the `subject_token_provider`
10+
callback. The callback receives the authorization server's issuer identifier as its audience.
11+
"""
12+
13+
import asyncio
14+
15+
import httpx
16+
17+
from mcp import ClientSession
18+
from mcp.client.auth.extensions.token_exchange import TokenExchangeOAuthProvider
19+
from mcp.client.streamable_http import streamable_http_client
20+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
21+
22+
23+
class InMemoryTokenStorage:
24+
"""Demo in-memory token storage."""
25+
26+
def __init__(self) -> None:
27+
self.tokens: OAuthToken | None = None
28+
self.client_info: OAuthClientInformationFull | None = None
29+
30+
async def get_tokens(self) -> OAuthToken | None:
31+
return self.tokens
32+
33+
async def set_tokens(self, tokens: OAuthToken) -> None:
34+
self.tokens = tokens
35+
36+
async def get_client_info(self) -> OAuthClientInformationFull | None:
37+
return self.client_info
38+
39+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
40+
self.client_info = client_info
41+
42+
43+
async def fetch_id_jag(audience: str) -> str:
44+
"""Return the ID-JAG to exchange.
45+
46+
`audience` is the MCP authorization server's issuer identifier; the returned ID-JAG must
47+
carry it as the `aud` claim. In production this exchanges the user's IdP ID token for an
48+
ID-JAG against the enterprise identity provider.
49+
"""
50+
raise NotImplementedError("Obtain the ID-JAG from your enterprise identity provider")
51+
52+
53+
async def main() -> None:
54+
oauth_auth = TokenExchangeOAuthProvider(
55+
server_url="http://localhost:8001/mcp",
56+
storage=InMemoryTokenStorage(),
57+
client_id="enterprise-mcp-client",
58+
subject_token_provider=fetch_id_jag,
59+
scopes="user",
60+
)
61+
62+
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:
63+
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
64+
async with ClientSession(read, write) as session:
65+
await session.initialize()
66+
tools = await session.list_tools()
67+
print(f"Available tools: {[tool.name for tool in tools.tools]}")
68+
69+
70+
def run() -> None:
71+
asyncio.run(main())
72+
73+
74+
if __name__ == "__main__":
75+
run()

examples/snippets/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ completion-client = "clients.completion_client:main"
2121
direct-execution-server = "servers.direct_execution:main"
2222
display-utilities-client = "clients.display_utilities:main"
2323
oauth-client = "clients.oauth_client:run"
24+
token-exchange-client = "clients.token_exchange_client:run"
2425
elicitation-client = "clients.url_elicitation_client:run"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Authorization-server side of SEP-990 (enterprise IdP policy controls).
2+
3+
An authorization server enables the RFC 8693 token-exchange grant by setting
4+
`token_exchange_enabled=True` and implementing `exchange_token` on its provider. The
5+
provider validates the subject token (the ID-JAG issued by the enterprise IdP), decides which
6+
scopes the exchange may grant, and mints an MCP access token.
7+
8+
Two responsibilities are the provider's and must not be skipped (both are stubbed here):
9+
- Validate the ID-JAG: signature against the IdP's keys, issuer/audience/expiry, and the
10+
organization's policy. Returning a subject for any non-empty token, as `_validate_id_jag`
11+
does below, is NOT safe for production.
12+
- Narrow the granted scopes. The scopes a client requests must never be granted verbatim; the
13+
ID-JAG and policy determine what is permitted. `_grant_scopes` intersects the request with
14+
what the subject is allowed.
15+
16+
Wire the returned routes into a Starlette app with `create_auth_routes(...,
17+
token_exchange_enabled=True)`, or set `AuthSettings(token_exchange_enabled=True)` when using
18+
`MCPServer`/`Server` with an `auth_server_provider`.
19+
"""
20+
21+
import secrets
22+
import time
23+
24+
from mcp.server.auth.provider import (
25+
AccessToken,
26+
AuthorizationCode,
27+
OAuthAuthorizationServerProvider,
28+
RefreshToken,
29+
TokenError,
30+
TokenExchangeParams,
31+
)
32+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken, TokenExchangeToken
33+
34+
# The scopes this server is willing to grant via token exchange. A real implementation derives
35+
# the permitted set from the validated ID-JAG and the organization's policy, not from a constant.
36+
ALLOWED_SCOPES = ("mcp",)
37+
38+
39+
class TokenExchangeProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
40+
"""Authorization-server provider that accepts an ID-JAG via RFC 8693 token exchange."""
41+
42+
def __init__(self) -> None:
43+
self.access_tokens: dict[str, AccessToken] = {}
44+
45+
async def exchange_token(self, client: OAuthClientInformationFull, params: TokenExchangeParams) -> OAuthToken:
46+
subject = self._validate_id_jag(params.subject_token)
47+
if subject is None:
48+
raise TokenError(error="invalid_grant", error_description="Invalid or rejected subject token")
49+
50+
scopes = self._grant_scopes(params.scopes)
51+
52+
assert client.client_id is not None
53+
access_token = f"access_{secrets.token_hex(16)}"
54+
self.access_tokens[access_token] = AccessToken(
55+
token=access_token,
56+
client_id=client.client_id,
57+
scopes=scopes,
58+
expires_at=int(time.time()) + 3600,
59+
resource=params.resource,
60+
subject=subject,
61+
)
62+
# TokenExchangeToken sets RFC 8693's issued_token_type; it defaults to the access-token type.
63+
return TokenExchangeToken(
64+
access_token=access_token,
65+
token_type="Bearer",
66+
expires_in=3600,
67+
scope=" ".join(scopes),
68+
)
69+
70+
def _validate_id_jag(self, subject_token: str) -> str | None:
71+
"""Validate the ID-JAG and return the subject, or None to reject.
72+
73+
A real implementation verifies the JWT signature against the IdP's keys, checks the
74+
issuer/audience/expiry, and applies the organization's policy. This stub accepts any
75+
non-empty token and uses it as the subject identifier - replace it before production.
76+
"""
77+
return subject_token or None
78+
79+
def _grant_scopes(self, requested: list[str] | None) -> list[str]:
80+
"""Return the scopes to grant, never exceeding what this server permits.
81+
82+
Requested scopes are intersected with the allowed set so a valid ID-JAG can never be
83+
exchanged for broader access than policy allows; when none are requested, the full
84+
allowed set is granted.
85+
"""
86+
if requested is None:
87+
return list(ALLOWED_SCOPES)
88+
granted = [scope for scope in requested if scope in ALLOWED_SCOPES]
89+
if not granted:
90+
raise TokenError(error="invalid_scope", error_description="No requested scope is permitted")
91+
return granted
92+
93+
async def load_access_token(self, token: str) -> AccessToken | None:
94+
return self.access_tokens.get(token)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""OAuth 2.0 Token Exchange (RFC 8693) client provider for MCP.
2+
3+
Provides `TokenExchangeOAuthProvider`, which exchanges a security token issued by an
4+
enterprise identity provider for an MCP access token at the MCP authorization server's
5+
token endpoint. This is the client side of SEP-990 (enterprise IdP policy controls): the
6+
client first obtains an Identity Assertion Authorization Grant (ID-JAG) from the IdP, then
7+
exchanges it here for a token usable against the MCP server.
8+
9+
Obtaining the ID-JAG (logging into the IdP and performing the first token exchange against
10+
it) is deployment-specific and out of scope for the SDK. The caller supplies it through the
11+
`subject_token_provider` callback, which receives the MCP authorization server's issuer
12+
identifier as its audience and returns the security token to exchange.
13+
"""
14+
15+
from collections.abc import Awaitable, Callable
16+
from typing import Any, Literal
17+
18+
import httpx
19+
20+
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
21+
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
22+
23+
TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
24+
JWT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
25+
ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
26+
27+
28+
class TokenExchangeOAuthProvider(OAuthClientProvider):
29+
"""OAuth provider for the RFC 8693 token-exchange grant.
30+
31+
Exchanges a subject token (for SEP-990, the IdP-issued ID-JAG) for an MCP access token,
32+
bypassing the interactive authorization-code flow and dynamic client registration. The
33+
subject token is fetched lazily from `subject_token_provider` so a fresh token is used on
34+
each exchange.
35+
36+
Example:
37+
```python
38+
async def fetch_id_jag(audience: str) -> str:
39+
# `audience` is the MCP authorization server's issuer identifier; the returned
40+
# ID-JAG must carry that as its `aud` claim. How the ID-JAG is obtained from the
41+
# enterprise IdP is deployment-specific and not handled by the SDK.
42+
return await my_idp.exchange_id_token_for_id_jag(audience=audience)
43+
44+
45+
provider = TokenExchangeOAuthProvider(
46+
server_url="https://mcp.example.com/mcp",
47+
storage=my_token_storage,
48+
client_id="my-client-id",
49+
subject_token_provider=fetch_id_jag,
50+
)
51+
```
52+
"""
53+
54+
def __init__(
55+
self,
56+
server_url: str,
57+
storage: TokenStorage,
58+
client_id: str,
59+
subject_token_provider: Callable[[str], Awaitable[str]],
60+
subject_token_type: str = JWT_TOKEN_TYPE,
61+
requested_token_type: str | None = ACCESS_TOKEN_TYPE,
62+
scopes: str | None = None,
63+
client_secret: str | None = None,
64+
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_post",
65+
) -> None:
66+
"""Initialize the token-exchange OAuth provider.
67+
68+
Args:
69+
server_url: The MCP server URL.
70+
storage: Token storage implementation.
71+
client_id: The OAuth client ID registered with the MCP authorization server.
72+
subject_token_provider: Async callback that takes the authorization server's
73+
issuer identifier (the audience) and returns the subject token to exchange
74+
(the ID-JAG for SEP-990).
75+
subject_token_type: RFC 8693 type identifier of the subject token. Defaults to
76+
`urn:ietf:params:oauth:token-type:jwt`, the type of an ID-JAG.
77+
requested_token_type: RFC 8693 desired type of the issued token. Defaults to
78+
`urn:ietf:params:oauth:token-type:access_token`, since SEP-990 yields an MCP
79+
access token; pass `None` to omit the parameter.
80+
scopes: Optional space-separated list of scopes to request.
81+
client_secret: Optional client secret. When set, the request authenticates as a
82+
confidential client using `token_endpoint_auth_method`; otherwise the public
83+
client form is used and the method is `none`.
84+
token_endpoint_auth_method: Authentication method when `client_secret` is set.
85+
Either `client_secret_post` (default) or `client_secret_basic`.
86+
"""
87+
auth_method = token_endpoint_auth_method if client_secret is not None else "none"
88+
client_metadata = OAuthClientMetadata(
89+
redirect_uris=None,
90+
grant_types=[TOKEN_EXCHANGE_GRANT_TYPE],
91+
token_endpoint_auth_method=auth_method,
92+
scope=scopes,
93+
)
94+
super().__init__(server_url, client_metadata, storage, None, None, 300.0)
95+
self._subject_token_provider = subject_token_provider
96+
self._subject_token_type = subject_token_type
97+
self._requested_token_type = requested_token_type
98+
self._fixed_client_info = OAuthClientInformationFull(
99+
redirect_uris=None,
100+
client_id=client_id,
101+
client_secret=client_secret,
102+
grant_types=[TOKEN_EXCHANGE_GRANT_TYPE],
103+
token_endpoint_auth_method=auth_method,
104+
scope=scopes,
105+
)
106+
107+
async def _initialize(self) -> None:
108+
"""Load stored tokens and set pre-configured client_info."""
109+
self.context.current_tokens = await self.context.storage.get_tokens()
110+
self.context.client_info = self._fixed_client_info
111+
self._initialized = True
112+
113+
async def _perform_authorization(self) -> httpx.Request:
114+
"""Perform the token-exchange grant."""
115+
return await self._exchange_token()
116+
117+
async def _exchange_token(self) -> httpx.Request:
118+
"""Build the RFC 8693 token-exchange request."""
119+
if not self.context.oauth_metadata:
120+
raise OAuthFlowError("Missing OAuth metadata for token exchange") # pragma: no cover
121+
if not self.context.client_info:
122+
raise OAuthFlowError("Missing client info for token exchange") # pragma: no cover
123+
124+
audience = str(self.context.oauth_metadata.issuer)
125+
subject_token = await self._subject_token_provider(audience)
126+
127+
token_data: dict[str, Any] = {
128+
"grant_type": TOKEN_EXCHANGE_GRANT_TYPE,
129+
"subject_token": subject_token,
130+
"subject_token_type": self._subject_token_type,
131+
"client_id": self.context.client_info.client_id,
132+
}
133+
if self._requested_token_type is not None:
134+
token_data["requested_token_type"] = self._requested_token_type
135+
136+
headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
137+
token_data, headers = self.context.prepare_token_auth(token_data, headers)
138+
139+
if self.context.should_include_resource_param(self.context.protocol_version):
140+
token_data["resource"] = self.context.get_resource_url()
141+
142+
if self.context.client_metadata.scope:
143+
token_data["scope"] = self.context.client_metadata.scope
144+
145+
token_url = self._get_token_endpoint()
146+
return httpx.Request("POST", token_url, data=token_data, headers=headers)

0 commit comments

Comments
 (0)