Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1445,6 +1445,37 @@ client_metadata = OAuthClientMetadata(

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.

### Identity Assertion Authorization Grant for enterprise IdP flows (SEP-990)

The SDK now supports SEP-990's enterprise identity-provider policy controls. The client presents an Identity Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise IdP - to the MCP authorization server using the RFC 7523 jwt-bearer grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`), and receives an MCP access token. This matches the SEP-990 normative profile and interoperates with the other MCP SDKs. (Leg 1 - exchanging the user's IdP ID token for the ID-JAG against the IdP - is deployment-specific and out of scope for the SDK.) This is additive and opt-in on both sides; existing flows are unchanged.

On the client, `IdentityAssertionOAuthProvider` (in `mcp.client.auth.extensions.identity_assertion`) is an `httpx.Auth` that posts the jwt-bearer request. The ID-JAG is supplied lazily through an async `assertion_provider(audience, resource)` callback - `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's identifier (the ID-JAG `resource` claim):

```python
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider


async def fetch_id_jag(audience: str, resource: str) -> str:
# The ID-JAG must carry `audience` as `aud` and `resource` as its `resource` claim.
return await my_idp.issue_id_jag(audience=audience, resource=resource)


provider = IdentityAssertionOAuthProvider(
server_url="https://mcp.example.com/mcp",
storage=my_token_storage,
client_id="enterprise-mcp-client",
client_secret="enterprise-mcp-secret",
issuer="https://auth.example.com",
assertion_provider=fetch_id_jag,
)
```

SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so `client_secret` is mandatory (`token_endpoint_auth_method` chooses `client_secret_post` (default) or `client_secret_basic`; the spec also permits `private_key_jwt`). The authorization server is configuration, not discovery: `issuer` is the AS the client is provisioned for, authorization-server metadata is fetched from that issuer's RFC 8414 well-known, and the resource server is never asked which AS to use - so a hostile resource server cannot redirect the ID-JAG or secret.

On the authorization server, set `AuthSettings(identity_assertion_enabled=True)` (or pass `identity_assertion_enabled=True` to `create_auth_routes`) and implement `exchange_identity_assertion` on your `OAuthAuthorizationServerProvider`. The method receives an `IdentityAssertionParams` (the ID-JAG `assertion`, requested scopes, and request `resource`) and returns a plain RFC 6749 `OAuthToken`. The flag gates both metadata advertisement and the token endpoint: when off, `/token` rejects the grant with `unsupported_grant_type` even if the provider implements the hook. When on, the metadata advertises the jwt-bearer grant and the `urn:ietf:params:oauth:grant-profile:id-jag` profile in `authorization_grant_profiles_supported` (the discovery mechanism per ext-auth §6).

The implementation is responsible for validating the assertion per RFC 7523 §3 and SEP-990 §5.1 - verify the signature/`iss`/`exp`/`typ`, require `aud` to be this AS, require the ID-JAG's `client_id` claim to match the authenticated client, audience-restrict the issued token to the ID-JAG's `resource` claim (not the client-controlled request `resource`), and derive scopes from the ID-JAG rather than granting the request verbatim. See `examples/snippets/servers/identity_assertion_server.py`, which fails closed. Two hardening points are enforced by the SDK: the handler rejects clients without a stored secret before calling the hook (and `ClientAuthenticator` itself now refuses a secret-based auth method registered without a secret), and Dynamic Client Registration refuses the jwt-bearer grant so the ID-JAG flow requires a pre-registered confidential client.

### 2025-11-25 and 2026-07-28 protocol fields modeled

`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.
Expand Down
82 changes: 82 additions & 0 deletions examples/snippets/clients/identity_assertion_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Client side of SEP-990 (enterprise IdP policy controls).

`IdentityAssertionOAuthProvider` presents an Identity Assertion Authorization Grant (ID-JAG) issued
by the enterprise IdP to the MCP authorization server using the RFC 7523 jwt-bearer grant
(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an
MCP access token. No browser redirect or dynamic client registration is involved.

Obtaining the ID-JAG (logging into the IdP and the leg-1 exchange against it) is deployment-specific
and out of scope for the SDK; supply it through the `assertion_provider` callback. The callback
receives the authorization server's issuer (the ID-JAG `aud`) and the MCP server's resource
identifier (the ID-JAG `resource` claim). SEP-990 requires a confidential client, so a client secret
is mandatory, and `issuer` is the authorization server the credentials are provisioned for - the
provider fetches metadata from that issuer's well-known and never asks the resource server which AS
to use.
"""

import asyncio

import httpx

from mcp import ClientSession
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken


class InMemoryTokenStorage:
"""Demo in-memory token storage."""

def __init__(self) -> None:
self.tokens: OAuthToken | None = None
self.client_info: OAuthClientInformationFull | None = None

async def get_tokens(self) -> OAuthToken | None:
return self.tokens

async def set_tokens(self, tokens: OAuthToken) -> None:
self.tokens = tokens

async def get_client_info(self) -> OAuthClientInformationFull | None:
return self.client_info

async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self.client_info = client_info


async def fetch_id_jag(audience: str, resource: str) -> str:
"""Return the ID-JAG to present.

`audience` is the MCP authorization server's issuer (the ID-JAG `aud` claim); `resource` is the
MCP server's RFC 9728 identifier (the ID-JAG `resource` claim, which the AS audience-restricts
the issued token against). In production this exchanges the user's IdP ID token for an ID-JAG
against the enterprise identity provider.
"""
raise NotImplementedError("Obtain the ID-JAG from your enterprise identity provider")


async def main() -> None:
oauth_auth = IdentityAssertionOAuthProvider(
server_url="http://localhost:8001/mcp",
storage=InMemoryTokenStorage(),
client_id="enterprise-mcp-client",
client_secret="enterprise-mcp-secret",
issuer="http://localhost:8001",
assertion_provider=fetch_id_jag,
scope="user",
)

async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print(f"Available tools: {[tool.name for tool in tools.tools]}")


def run() -> None:
asyncio.run(main())


if __name__ == "__main__":
run()
1 change: 1 addition & 0 deletions examples/snippets/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ completion-client = "clients.completion_client:main"
direct-execution-server = "servers.direct_execution:main"
display-utilities-client = "clients.display_utilities:main"
oauth-client = "clients.oauth_client:run"
identity-assertion-client = "clients.identity_assertion_client:run"
elicitation-client = "clients.url_elicitation_client:run"
103 changes: 103 additions & 0 deletions examples/snippets/servers/identity_assertion_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Authorization-server side of SEP-990 (enterprise IdP policy controls).

An authorization server enables the Identity Assertion Authorization Grant by setting
`identity_assertion_enabled=True` and implementing `exchange_identity_assertion` on its provider.
The client presents the IdP-issued ID-JAG using the RFC 7523 jwt-bearer grant; the provider
validates the assertion and mints an MCP access token.

Validating the ID-JAG is the provider's responsibility and is only stubbed here. A real
implementation MUST, per RFC 7523 §3 and SEP-990 §5.1:

- verify the JWT signature, `iss`, and `exp`, and that `typ` is `oauth-id-jag+jwt`;
- require `aud` to identify this authorization server;
- require the ID-JAG's `client_id` claim to match the authenticated client;
- audience-restrict the issued token to the resource named in the ID-JAG's `resource` claim
(NOT the client-supplied `params.resource`);
- derive the granted scopes from the ID-JAG and policy.

`_decode_and_validate_id_jag` below raises `NotImplementedError` so this snippet fails closed and
forces a real implementation. Wire the returned routes into a Starlette app with
`create_auth_routes(..., identity_assertion_enabled=True)`, or set
`AuthSettings(identity_assertion_enabled=True)` with `MCPServer`/`Server`.
"""

import secrets
import time
from dataclasses import dataclass

from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
RefreshToken,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken


@dataclass
class IdJagClaims:
"""The trusted claims extracted from a validated ID-JAG."""

subject: str # the end user the ID-JAG was issued for
client_id: str # the ID-JAG `client_id` claim; §5.1 requires it to match the authenticated client
resource: str # the MCP server the issued token must be audience-restricted to
scopes: list[str]


class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
"""Authorization-server provider that accepts an ID-JAG via the RFC 7523 jwt-bearer grant."""

def __init__(self) -> None:
self.access_tokens: dict[str, AccessToken] = {}
# SEP-990 clients are pre-registered out of band (DCR refuses the grant) and must be
# confidential. `get_client` must return them, or the token endpoint 401s before the
# exchange runs. Real deployments load these from their own store.
self.clients: dict[str, OAuthClientInformationFull] = {
"enterprise-mcp-client": OAuthClientInformationFull(
client_id="enterprise-mcp-client",
client_secret="enterprise-mcp-secret",
redirect_uris=None,
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
token_endpoint_auth_method="client_secret_post",
)
}

async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)

async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
claims = self._decode_and_validate_id_jag(params.assertion, client)

access_token = f"access_{secrets.token_hex(16)}"
self.access_tokens[access_token] = AccessToken(
token=access_token,
client_id=claims.client_id,
scopes=claims.scopes,
expires_at=int(time.time()) + 3600,
# Bind to the resource from the validated ID-JAG, not the client-controlled request.
resource=claims.resource,
subject=claims.subject,
)
# No refresh token: SEP-990 relies on the IdP re-issuing ID-JAGs to control session lifetime.
return OAuthToken(
access_token=access_token,
token_type="Bearer",
expires_in=3600,
scope=" ".join(claims.scopes),
)

def _decode_and_validate_id_jag(self, assertion: str, client: OAuthClientInformationFull) -> IdJagClaims:
"""Verify the ID-JAG and return its trusted claims, or reject the request.

Replace this stub with real RFC 7523 §3 / SEP-990 §5.1 validation. It fails closed - it
raises rather than trusting the assertion - so a copy of this example cannot accidentally
accept unverified tokens. RFC 7523 §3.1 / RFC 6749 §5.2 specify `invalid_grant` for a
rejected assertion.
"""
raise NotImplementedError("Validate the ID-JAG (signature, iss/aud/exp/typ, client_id, resource)")
Comment thread
claude[bot] marked this conversation as resolved.

async def load_access_token(self, token: str) -> AccessToken | None:
return self.access_tokens.get(token)
Loading
Loading