Skip to content

Commit aeb0a57

Browse files
committed
Remove the deprecated RFC7523OAuthClientProvider
The provider implemented the RFC 7523 §2.1 jwt-bearer authorization grant with an SDK-minted or prebuilt JWT, which no MCP auth extension specifies, and has been deprecated since 1.23.0 in favour of ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider, and IdentityAssertionOAuthProvider. Drop it and its JWTParameters model ahead of v2, with a migration entry mapping each of its modes to a replacement.
1 parent 5dd062d commit aeb0a57

4 files changed

Lines changed: 29 additions & 306 deletions

File tree

docs/migration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,6 +1920,32 @@ The WebSocket transport has been removed: `mcp.client.websocket.websocket_client
19201920

19211921
## OAuth and server auth
19221922

1923+
### `RFC7523OAuthClientProvider` and `JWTParameters` removed
1924+
1925+
`RFC7523OAuthClientProvider` (deprecated since 1.23.0) and its `JWTParameters` model have been
1926+
removed from `mcp.client.auth.extensions.client_credentials`. The provider implemented the
1927+
[RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §2.1 `jwt-bearer` *authorization grant*
1928+
with an SDK-minted or prebuilt JWT, which no MCP auth extension specifies. Replace it with the
1929+
purpose-built provider for the flow you actually run:
1930+
1931+
- Machine-to-machine with a client secret
1932+
([`io.modelcontextprotocol/oauth-client-credentials`](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials)):
1933+
`ClientCredentialsOAuthProvider(server_url, storage, client_id, client_secret)`.
1934+
- Machine-to-machine authenticating with a JWT instead of a secret (same extension, RFC 7523 §2.2
1935+
`private_key_jwt` client authentication on the `client_credentials` grant, which is the mode the
1936+
extension actually specifies for JWTs): `PrivateKeyJWTOAuthProvider(server_url, storage,
1937+
client_id, assertion_provider)`. Build the assertion with `SignedJWTParameters(issuer, subject,
1938+
signing_key).create_assertion_provider()` (replaces `JWTParameters` signing fields), or wrap a
1939+
prebuilt JWT with `static_assertion_provider(token)` (replaces `JWTParameters(assertion=...)`).
1940+
- Presenting an enterprise ID-JAG under the `jwt-bearer` grant
1941+
([SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)):
1942+
`IdentityAssertionOAuthProvider` in `mcp.client.auth.extensions.identity_assertion`.
1943+
1944+
The provider's third mode — the interactive `authorization_code` flow with `private_key_jwt`
1945+
client authentication on the token exchange — has no replacement and is intentionally dropped; it
1946+
was never exercised by the test suite and no MCP auth extension specifies it. If you depended on
1947+
it, open an issue describing the deployment.
1948+
19231949
### OAuth metadata URLs no longer gain a trailing slash
19241950

19251951
`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set

src/mcp/client/auth/extensions/client_credentials.py

Lines changed: 2 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44
- ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret
55
- PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt authentication
66
(typically using a pre-built JWT from workload identity federation)
7-
- RFC7523OAuthClientProvider: For jwt-bearer grant (RFC 7523 Section 2.1)
87
"""
98

109
import time
11-
import warnings
1210
from collections.abc import Awaitable, Callable
1311
from typing import Any, Literal
1412
from uuid import uuid4
@@ -17,9 +15,8 @@
1715
import jwt
1816
from pydantic import BaseModel, Field
1917

20-
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
21-
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata
22-
from mcp.shared.exceptions import MCPDeprecationWarning
18+
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
19+
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2320

2421

2522
class ClientCredentialsOAuthProvider(OAuthClientProvider):
@@ -334,153 +331,3 @@ async def _exchange_token_client_credentials(self) -> httpx2.Request:
334331

335332
token_url = self._get_token_endpoint()
336333
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
337-
338-
339-
class JWTParameters(BaseModel):
340-
"""JWT parameters."""
341-
342-
assertion: str | None = Field(
343-
default=None,
344-
description="JWT assertion for JWT authentication. "
345-
"Will be used instead of generating a new assertion if provided.",
346-
)
347-
348-
issuer: str | None = Field(default=None, description="Issuer for JWT assertions.")
349-
subject: str | None = Field(default=None, description="Subject identifier for JWT assertions.")
350-
audience: str | None = Field(default=None, description="Audience for JWT assertions.")
351-
claims: dict[str, Any] | None = Field(default=None, description="Additional claims for JWT assertions.")
352-
jwt_signing_algorithm: str | None = Field(default="RS256", description="Algorithm for signing JWT assertions.")
353-
jwt_signing_key: str | None = Field(default=None, description="Private key for JWT signing.")
354-
jwt_lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.")
355-
356-
def to_assertion(self, with_audience_fallback: str | None = None) -> str:
357-
if self.assertion is not None:
358-
# Prebuilt JWT (e.g. acquired out-of-band)
359-
assertion = self.assertion
360-
else:
361-
if not self.jwt_signing_key:
362-
raise OAuthFlowError("Missing signing key for JWT bearer grant") # pragma: no cover
363-
if not self.issuer:
364-
raise OAuthFlowError("Missing issuer for JWT bearer grant") # pragma: no cover
365-
if not self.subject:
366-
raise OAuthFlowError("Missing subject for JWT bearer grant") # pragma: no cover
367-
368-
audience = self.audience if self.audience else with_audience_fallback
369-
if not audience:
370-
raise OAuthFlowError("Missing audience for JWT bearer grant") # pragma: no cover
371-
372-
now = int(time.time())
373-
claims: dict[str, Any] = {
374-
"iss": self.issuer,
375-
"sub": self.subject,
376-
"aud": audience,
377-
"exp": now + self.jwt_lifetime_seconds,
378-
"iat": now,
379-
"jti": str(uuid4()),
380-
}
381-
claims.update(self.claims or {})
382-
383-
assertion = jwt.encode(
384-
claims,
385-
self.jwt_signing_key,
386-
algorithm=self.jwt_signing_algorithm or "RS256",
387-
)
388-
return assertion
389-
390-
391-
class RFC7523OAuthClientProvider(OAuthClientProvider):
392-
"""OAuth client provider for RFC 7523 jwt-bearer grant.
393-
394-
.. deprecated::
395-
Use :class:`ClientCredentialsOAuthProvider` for client_credentials with
396-
client_id + client_secret, or :class:`PrivateKeyJWTOAuthProvider` for
397-
client_credentials with private_key_jwt authentication instead.
398-
399-
This provider supports the jwt-bearer authorization grant (RFC 7523 Section 2.1)
400-
where the JWT itself is the authorization grant.
401-
"""
402-
403-
def __init__(
404-
self,
405-
server_url: str,
406-
client_metadata: OAuthClientMetadata,
407-
storage: TokenStorage,
408-
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
409-
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
410-
timeout: float = 300.0,
411-
jwt_parameters: JWTParameters | None = None,
412-
) -> None:
413-
warnings.warn(
414-
"RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider "
415-
"or PrivateKeyJWTOAuthProvider instead.",
416-
MCPDeprecationWarning,
417-
stacklevel=2,
418-
)
419-
super().__init__(server_url, client_metadata, storage, redirect_handler, callback_handler, timeout)
420-
self.jwt_parameters = jwt_parameters
421-
422-
async def _exchange_token_authorization_code(
423-
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None
424-
) -> httpx2.Request: # pragma: no cover
425-
"""Build token exchange request for authorization_code flow."""
426-
token_data = token_data or {}
427-
if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt":
428-
self._add_client_authentication_jwt(token_data=token_data)
429-
return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data)
430-
431-
async def _perform_authorization(self) -> httpx2.Request: # pragma: no cover
432-
"""Perform the authorization flow."""
433-
if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
434-
token_request = await self._exchange_token_jwt_bearer()
435-
return token_request
436-
else:
437-
return await super()._perform_authorization()
438-
439-
def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # pragma: no cover
440-
"""Add JWT assertion for client authentication to token endpoint parameters."""
441-
if not self.jwt_parameters:
442-
raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow")
443-
if not self.context.oauth_metadata:
444-
raise OAuthTokenError("Missing OAuth metadata for private_key_jwt flow")
445-
446-
# We need to set the audience to the issuer identifier of the authorization server
447-
# https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
448-
issuer = str(self.context.oauth_metadata.issuer)
449-
assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer)
450-
451-
# When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2
452-
token_data["client_assertion"] = assertion
453-
token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
454-
# We need to set the audience to the resource server, the audience is different from the one in claims
455-
# it represents the resource server that will validate the token
456-
token_data["audience"] = self.context.get_resource_url()
457-
458-
async def _exchange_token_jwt_bearer(self) -> httpx2.Request:
459-
"""Build token exchange request for JWT bearer grant."""
460-
if not self.context.client_info:
461-
raise OAuthFlowError("Missing client info") # pragma: no cover
462-
if not self.jwt_parameters:
463-
raise OAuthFlowError("Missing JWT parameters") # pragma: no cover
464-
if not self.context.oauth_metadata:
465-
raise OAuthTokenError("Missing OAuth metadata") # pragma: no cover
466-
467-
# We need to set the audience to the issuer identifier of the authorization server
468-
# https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
469-
issuer = str(self.context.oauth_metadata.issuer)
470-
assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer)
471-
472-
token_data = {
473-
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
474-
"assertion": assertion,
475-
}
476-
477-
if self.context.should_include_resource_param(self.context.protocol_version): # pragma: no branch
478-
token_data["resource"] = self.context.get_resource_url()
479-
480-
if self.context.client_metadata.scope: # pragma: no branch
481-
token_data["scope"] = self.context.client_metadata.scope
482-
483-
token_url = self._get_token_endpoint()
484-
return httpx2.Request(
485-
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
486-
)

tests/client/auth/extensions/test_client_credentials.py

Lines changed: 1 addition & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,20 @@
11
import urllib.parse
2-
import warnings
32

43
import jwt
54
import pytest
6-
from pydantic import AnyHttpUrl, AnyUrl
5+
from pydantic import AnyHttpUrl
76

87
from mcp.client.auth.extensions.client_credentials import (
98
ClientCredentialsOAuthProvider,
10-
JWTParameters,
119
PrivateKeyJWTOAuthProvider,
12-
RFC7523OAuthClientProvider,
1310
SignedJWTParameters,
1411
static_assertion_provider,
1512
)
1613
from mcp.shared.auth import (
17-
AuthorizationCodeResult,
1814
OAuthClientInformationFull,
19-
OAuthClientMetadata,
2015
OAuthMetadata,
2116
OAuthToken,
2217
)
23-
from mcp.shared.exceptions import MCPDeprecationWarning
2418

2519

2620
class MockTokenStorage:
@@ -48,138 +42,6 @@ def mock_storage():
4842
return MockTokenStorage()
4943

5044

51-
@pytest.fixture
52-
def client_metadata():
53-
return OAuthClientMetadata(
54-
client_name="Test Client",
55-
client_uri=AnyHttpUrl("https://example.com"),
56-
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
57-
scope="read write",
58-
)
59-
60-
61-
@pytest.fixture
62-
def rfc7523_oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage):
63-
async def redirect_handler(url: str) -> None: # pragma: no cover
64-
"""Mock redirect handler."""
65-
pass
66-
67-
async def callback_handler() -> AuthorizationCodeResult: # pragma: no cover
68-
"""Mock callback handler."""
69-
return AuthorizationCodeResult(code="test_auth_code", state="test_state")
70-
71-
with warnings.catch_warnings():
72-
warnings.simplefilter("ignore", MCPDeprecationWarning)
73-
return RFC7523OAuthClientProvider(
74-
server_url="https://api.example.com/v1/mcp",
75-
client_metadata=client_metadata,
76-
storage=mock_storage,
77-
redirect_handler=redirect_handler,
78-
callback_handler=callback_handler,
79-
)
80-
81-
82-
class TestOAuthFlowClientCredentials:
83-
"""Test OAuth flow behavior for client credentials flows."""
84-
85-
@pytest.mark.anyio
86-
async def test_token_exchange_request_jwt_predefined(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
87-
"""Test token exchange request building with a predefined JWT assertion."""
88-
# Set up required context
89-
rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
90-
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
91-
token_endpoint_auth_method="private_key_jwt",
92-
redirect_uris=None,
93-
scope="read write",
94-
)
95-
rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
96-
issuer=AnyHttpUrl("https://api.example.com"),
97-
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
98-
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
99-
registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
100-
)
101-
rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
102-
rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
103-
rfc7523_oauth_provider.jwt_parameters = JWTParameters(
104-
# https://www.jwt.io
105-
assertion="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
106-
)
107-
108-
request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
109-
110-
assert request.method == "POST"
111-
assert str(request.url) == "https://api.example.com/token"
112-
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
113-
114-
# Check form data
115-
content = urllib.parse.unquote_plus(request.content.decode())
116-
assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
117-
assert "scope=read write" in content
118-
assert "resource=https://api.example.com/v1/mcp" in content
119-
assert (
120-
"assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
121-
in content
122-
)
123-
124-
@pytest.mark.anyio
125-
async def test_token_exchange_request_jwt(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
126-
"""Test token exchange request building wiith a generated JWT assertion."""
127-
# Set up required context
128-
rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
129-
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
130-
token_endpoint_auth_method="private_key_jwt",
131-
redirect_uris=None,
132-
scope="read write",
133-
)
134-
rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
135-
issuer=AnyHttpUrl("https://api.example.com"),
136-
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
137-
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
138-
registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
139-
)
140-
rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
141-
rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
142-
rfc7523_oauth_provider.jwt_parameters = JWTParameters(
143-
issuer="foo",
144-
subject="1234567890",
145-
claims={
146-
"name": "John Doe",
147-
"admin": True,
148-
"iat": 1516239022,
149-
},
150-
jwt_signing_algorithm="HS256",
151-
jwt_signing_key="a-string-secret-at-least-256-bits-long",
152-
jwt_lifetime_seconds=300,
153-
)
154-
155-
request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
156-
157-
assert request.method == "POST"
158-
assert str(request.url) == "https://api.example.com/token"
159-
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
160-
161-
# Check form data
162-
content = urllib.parse.unquote_plus(request.content.decode()).split("&")
163-
assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
164-
assert "scope=read write" in content
165-
assert "resource=https://api.example.com/v1/mcp" in content
166-
167-
# Check assertion
168-
assertion = next(param for param in content if param.startswith("assertion="))[len("assertion=") :]
169-
claims = jwt.decode(
170-
assertion,
171-
key="a-string-secret-at-least-256-bits-long",
172-
algorithms=["HS256"],
173-
audience="https://api.example.com/",
174-
subject="1234567890",
175-
issuer="foo",
176-
verify=True,
177-
)
178-
assert claims["name"] == "John Doe"
179-
assert claims["admin"]
180-
assert claims["iat"] == 1516239022
181-
182-
18345
class TestClientCredentialsOAuthProvider:
18446
"""Test ClientCredentialsOAuthProvider."""
18547

0 commit comments

Comments
 (0)