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 95 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 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
12d0662
Merge branch 'main' into main
SoldierSacha Jul 22, 2025
78868cc
merge with recent branch
SoldierSacha Jul 22, 2025
7dff18a
merge with recent branch
SoldierSacha Jul 22, 2025
8c9f31f
merge with recent branch
SoldierSacha Jul 22, 2025
31edf42
Merge branch 'main' into main
SoldierSacha Jul 25, 2025
ac02caf
Merge branch 'main' into main
SoldierSacha Jul 26, 2025
a6f77c4
merge with recent branch
SoldierSacha Jul 26, 2025
52fee87
Merge branch 'main' into main
SoldierSacha Aug 4, 2025
545d047
Merge branch 'main' into main
SoldierSacha Aug 4, 2025
d1bbbac
Merge branch 'main' into main
SoldierSacha Aug 15, 2025
710a567
merge with recent branch
SoldierSacha Aug 15, 2025
bafa7a8
refactor: unify OAuth providers and support basic auth
SoldierSacha Aug 15, 2025
10a7a79
Merge pull request #18 from sacha-development-stuff/codex/analyze-cod…
SoldierSacha Aug 15, 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
473 changes: 388 additions & 85 deletions src/mcp/client/auth.py

Large diffs are not rendered by default.

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
15 changes: 13 additions & 2 deletions src/mcp/server/auth/handlers/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,22 @@ async def handle(self, request: Request) -> Response:
),
status_code=400,
)
if set(client_metadata.grant_types) != {"authorization_code", "refresh_token"}:
grant_types_set: set[str] = set(client_metadata.grant_types)
valid_sets = [
{"authorization_code", "refresh_token"},
{"client_credentials"},
{"token_exchange"},
{"client_credentials", "token_exchange"},
]

if grant_types_set not in valid_sets:
return PydanticJSONResponse(
content=RegistrationErrorResponse(
error="invalid_client_metadata",
error_description="grant_types must be authorization_code and refresh_token",
error_description=(
"grant_types must be authorization_code and refresh_token "
"or client_credentials or token exchange or client_credentials and token_exchange"
),
),
status_code=400,
)
Expand Down
68 changes: 65 additions & 3 deletions src/mcp/server/auth/handlers/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,39 @@ class RefreshTokenRequest(BaseModel):
resource: str | None = Field(None, description="Resource indicator for the token")


class ClientCredentialsRequest(BaseModel):
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
grant_type: Literal["client_credentials"]
scope: str | None = Field(None, description="Optional scope parameter")
client_id: str
client_secret: str | None = None


class TokenExchangeRequest(BaseModel):
"""RFC 8693 token exchange request."""

grant_type: Literal["token_exchange"]
subject_token: str = Field(..., description="Token to exchange")
subject_token_type: str = Field(..., description="Type of the subject token")
actor_token: str | None = Field(None, description="Optional actor token")
actor_token_type: str | None = Field(None, description="Type of the actor token if provided")
resource: str | None = None
audience: str | None = None
scope: str | None = None
client_id: str
client_secret: str | None = None


class TokenRequest(
RootModel[
Annotated[
AuthorizationCodeRequest | RefreshTokenRequest,
AuthorizationCodeRequest | RefreshTokenRequest | ClientCredentialsRequest | TokenExchangeRequest,
Field(discriminator="grant_type"),
]
]
):
root: Annotated[
AuthorizationCodeRequest | RefreshTokenRequest,
AuthorizationCodeRequest | RefreshTokenRequest | ClientCredentialsRequest | TokenExchangeRequest,
Field(discriminator="grant_type"),
]

Expand Down Expand Up @@ -192,10 +215,49 @@ async def handle(self, request: Request):
)
)

case ClientCredentialsRequest():
scopes = (
token_request.scope.split(" ")
if token_request.scope
else client_info.scope.split(" ")
if client_info.scope
else []
)
try:
tokens = await self.provider.exchange_client_credentials(client_info, scopes)
except TokenError as e:
return self.response(
TokenErrorResponse(
error=e.error,
error_description=e.error_description,
)
)

case TokenExchangeRequest():
scopes = token_request.scope.split(" ") if token_request.scope else []
try:
tokens = await self.provider.exchange_token(
client_info,
token_request.subject_token,
token_request.subject_token_type,
token_request.actor_token,
token_request.actor_token_type,
scopes,
token_request.audience,
token_request.resource,
)
except TokenError as e:
return self.response(
TokenErrorResponse(
error=e.error,
error_description=e.error_description,
)
)

case RefreshTokenRequest():
refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token)
if refresh_token is None or refresh_token.client_id != token_request.client_id:
# if token belongs to different client, pretend it doesn't exist
# if token belongs to a different client, pretend it doesn't exist
return self.response(
TokenErrorResponse(
error="invalid_grant",
Expand Down
19 changes: 19 additions & 0 deletions src/mcp/server/auth/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class AuthorizeError(Exception):
"unauthorized_client",
"unsupported_grant_type",
"invalid_scope",
"invalid_target",
]


Expand Down Expand Up @@ -248,6 +249,24 @@ async def exchange_refresh_token(
"""
...

async def exchange_client_credentials(self, client: OAuthClientInformationFull, scopes: list[str]) -> OAuthToken:
"""Exchange client credentials for an MCP access token."""
...

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."""
...

async def load_access_token(self, token: str) -> AccessTokenT | None:
"""
Loads an access token by its token.
Expand Down
7 changes: 6 additions & 1 deletion src/mcp/server/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,12 @@ def build_metadata(
scopes_supported=client_registration_options.valid_scopes,
response_types_supported=["code"],
response_modes_supported=None,
grant_types_supported=["authorization_code", "refresh_token"],
grant_types_supported=[
"authorization_code",
"refresh_token",
"client_credentials",
"token_exchange",
],
token_endpoint_auth_methods_supported=["client_secret_post"],
token_endpoint_auth_signing_alg_values_supported=None,
service_documentation=service_documentation_url,
Expand Down
45 changes: 39 additions & 6 deletions src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class OAuthToken(BaseModel):
expires_in: int | None = None
scope: str | None = None
refresh_token: str | None = None
issued_token_type: str | None = None

@field_validator("token_type", mode="before")
@classmethod
Expand Down Expand Up @@ -46,8 +47,15 @@ class OAuthClientMetadata(BaseModel):
# client_secret_post;
# ie: we do not support client_secret_basic
token_endpoint_auth_method: Literal["none", "client_secret_post"] = "client_secret_post"
# grant_types: this implementation only supports authorization_code & refresh_token
grant_types: list[Literal["authorization_code", "refresh_token"]] = [
# grant_types: this implementation supports authorization_code, refresh_token, client_credentials, & token_exchange
grant_types: list[
Literal[
"authorization_code",
"refresh_token",
"client_credentials",
"token_exchange",
]
] = [
"authorization_code",
"refresh_token",
]
Expand Down Expand Up @@ -114,10 +122,35 @@ class OAuthMetadata(BaseModel):
registration_endpoint: AnyHttpUrl | None = None
scopes_supported: list[str] | None = None
response_types_supported: list[str] = ["code"]
response_modes_supported: list[str] | None = None
grant_types_supported: list[str] | None = None
token_endpoint_auth_methods_supported: list[str] | None = None
token_endpoint_auth_signing_alg_values_supported: list[str] | None = None
response_modes_supported: (
list[
Literal[
"query",
"fragment",
"form_post",
"query.jwt",
"fragment.jwt",
"form_post.jwt",
"jwt",
]
]
| None
) = None
grant_types_supported: (
list[
Literal[
"authorization_code",
"refresh_token",
"client_credentials",
"token_exchange",
]
]
| None
) = None
token_endpoint_auth_methods_supported: list[Literal["none", "client_secret_post", "client_secret_basic"]] | None = (
None
)
token_endpoint_auth_signing_alg_values_supported: None = None
service_documentation: AnyHttpUrl | None = None
ui_locales_supported: list[str] | None = None
op_policy_uri: AnyHttpUrl | None = None
Expand Down
5 changes: 4 additions & 1 deletion src/mcp/shared/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,10 @@ async def send_notification(
message=JSONRPCMessage(jsonrpc_notification),
metadata=ServerMessageMetadata(related_request_id=related_request_id) if related_request_id else None,
)
await self._write_stream.send(session_message)
try:
await self._write_stream.send(session_message)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logging.debug("Discarding notification due to closed stream")

async def _send_response(self, request_id: RequestId, response: SendResultT | ErrorData) -> None:
if isinstance(response, ErrorData):
Expand Down
Loading
Loading