Skip to content

Commit e8bcd85

Browse files
committed
feat: add SEP-1046 client_credentials grant with private_key_jwt auth
Implements OAuth client_credentials grant type with JWT client authentication per SEP-1046 and RFC 7523 Section 2.2. Changes to RFC7523OAuthClientProvider: - Add _exchange_token_client_credentials() for client_credentials grant - Update _perform_authorization() to check for client_credentials first - Preserve legacy jwt-bearer grant flow for backwards compatibility Updates to conformance auth client: - Accept scenario name as first argument - Add run_client_credentials_client() for auth/client-credentials-jwt scenario - Use well-known conformance test EC P-256 private key
1 parent 27279bc commit e8bcd85

File tree

2 files changed

+121
-11
lines changed

2 files changed

+121
-11
lines changed

examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
fetching the authorization URL and extracting the auth code from the redirect.
88
99
Usage:
10-
python -m mcp_conformance_auth_client <server-url>
10+
python -m mcp_conformance_auth_client <scenario> <server-url>
11+
12+
Scenarios:
13+
auth/* - Authorization code flow scenarios (default behavior)
14+
auth/client-credentials-jwt - Client credentials with JWT authentication (SEP-1046)
1115
"""
1216

1317
import asyncio
@@ -19,10 +23,23 @@
1923
import httpx
2024
from mcp import ClientSession
2125
from mcp.client.auth import OAuthClientProvider, TokenStorage
26+
from mcp.client.auth.extensions.client_credentials import JWTParameters, RFC7523OAuthClientProvider
2227
from mcp.client.streamable_http import streamablehttp_client
2328
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
2429
from pydantic import AnyUrl
2530

31+
# Well-known test private key for conformance testing (EC P-256).
32+
# This corresponds to the public key in the conformance test framework.
33+
# The key is intentionally public for conformance testing purposes.
34+
CONFORMANCE_TEST_PRIVATE_KEY_PEM = """-----BEGIN EC PRIVATE KEY-----
35+
MHcCAQEEIAIbFaPsD3ShCQpvGzgyEIvLQo8pLOsEMwQWcRq+CwWGoAoGCCqGSM49
36+
AwEHoUQDQgAEiYsmK6YG68IEZ4k0vVSg1Cy8PRwsmz/XweRkTsYof+yVMH+FYm5P
37+
0IhAPaKV/4DjucO6UcQ+d/8q7i//mT7WQA==
38+
-----END EC PRIVATE KEY-----"""
39+
40+
# The client_id expected by the conformance test
41+
CONFORMANCE_TEST_CLIENT_ID = "conformance-test-client"
42+
2643
# Set up logging to stderr (stdout is for conformance test output)
2744
logging.basicConfig(
2845
level=logging.DEBUG,
@@ -111,17 +128,17 @@ async def handle_callback(self) -> tuple[str, str | None]:
111128
return auth_code, state
112129

113130

114-
async def run_client(server_url: str) -> None:
131+
async def run_authorization_code_client(server_url: str) -> None:
115132
"""
116-
Run the conformance test client against the given server URL.
133+
Run the conformance test client with authorization code flow.
117134
118135
This function:
119-
1. Connects to the MCP server with OAuth authentication
136+
1. Connects to the MCP server with OAuth authorization code flow
120137
2. Initializes the session
121138
3. Lists available tools
122139
4. Calls a test tool
123140
"""
124-
logger.debug(f"Starting conformance auth client for {server_url}")
141+
logger.debug(f"Starting conformance auth client (authorization_code) for {server_url}")
125142

126143
# Create callback handler that will automatically fetch auth codes
127144
callback_handler = ConformanceOAuthCallbackHandler()
@@ -140,6 +157,50 @@ async def run_client(server_url: str) -> None:
140157
callback_handler=callback_handler.handle_callback,
141158
)
142159

160+
await _run_session(server_url, oauth_auth)
161+
162+
163+
async def run_client_credentials_client(server_url: str) -> None:
164+
"""
165+
Run the conformance test client with client credentials flow (SEP-1046).
166+
167+
This function:
168+
1. Connects to the MCP server with OAuth client_credentials grant
169+
2. Uses private_key_jwt authentication with the well-known test key
170+
3. Initializes the session
171+
4. Lists available tools
172+
5. Calls a test tool
173+
"""
174+
logger.debug(f"Starting conformance auth client (client_credentials) for {server_url}")
175+
176+
# Create JWT parameters for private_key_jwt authentication
177+
jwt_params = JWTParameters(
178+
issuer=CONFORMANCE_TEST_CLIENT_ID,
179+
subject=CONFORMANCE_TEST_CLIENT_ID,
180+
jwt_signing_algorithm="ES256",
181+
jwt_signing_key=CONFORMANCE_TEST_PRIVATE_KEY_PEM,
182+
)
183+
184+
# Create OAuth authentication handler for client_credentials flow
185+
# Note: redirect_uris is required by the model but not used in client_credentials flow
186+
oauth_auth = RFC7523OAuthClientProvider(
187+
server_url=server_url,
188+
client_metadata=OAuthClientMetadata(
189+
client_name=CONFORMANCE_TEST_CLIENT_ID,
190+
redirect_uris=[AnyUrl("http://localhost:0/unused")], # Required but unused
191+
grant_types=["client_credentials"],
192+
response_types=[],
193+
token_endpoint_auth_method="private_key_jwt",
194+
),
195+
storage=InMemoryTokenStorage(),
196+
jwt_parameters=jwt_params,
197+
)
198+
199+
await _run_session(server_url, oauth_auth)
200+
201+
202+
async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
203+
"""Common session logic for all OAuth flows."""
143204
# Connect using streamable HTTP transport with OAuth
144205
async with streamablehttp_client(
145206
url=server_url,
@@ -168,14 +229,23 @@ async def run_client(server_url: str) -> None:
168229

169230
def main() -> None:
170231
"""Main entry point for the conformance auth client."""
171-
if len(sys.argv) != 2:
172-
print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr)
232+
if len(sys.argv) != 3:
233+
print(f"Usage: {sys.argv[0]} <scenario> <server-url>", file=sys.stderr)
234+
print("", file=sys.stderr)
235+
print("Scenarios:", file=sys.stderr)
236+
print(" auth/* - Authorization code flow (default)", file=sys.stderr)
237+
print(" auth/client-credentials-jwt - Client credentials with JWT auth (SEP-1046)", file=sys.stderr)
173238
sys.exit(1)
174239

175-
server_url = sys.argv[1]
240+
scenario = sys.argv[1]
241+
server_url = sys.argv[2]
176242

177243
try:
178-
asyncio.run(run_client(server_url))
244+
if scenario == "auth/client-credentials-jwt":
245+
asyncio.run(run_client_credentials_client(server_url))
246+
else:
247+
# Default to authorization code flow for all other auth/* scenarios
248+
asyncio.run(run_authorization_code_client(server_url))
179249
except Exception:
180250
logger.exception("Client failed")
181251
sys.exit(1)

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ async def _exchange_token_authorization_code(
9292

9393
async def _perform_authorization(self) -> httpx.Request: # pragma: no cover
9494
"""Perform the authorization flow."""
95-
if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
95+
if "client_credentials" in self.context.client_metadata.grant_types:
96+
# SEP-1046: client_credentials grant with private_key_jwt authentication
97+
token_request = await self._exchange_token_client_credentials()
98+
return token_request
99+
elif "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
100+
# RFC 7523 Section 2.1: JWT bearer grant (legacy)
96101
token_request = await self._exchange_token_jwt_bearer()
97102
return token_request
98103
else:
@@ -117,8 +122,43 @@ def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # prag
117122
# it represents the resource server that will validate the token
118123
token_data["audience"] = self.context.get_resource_url()
119124

125+
async def _exchange_token_client_credentials(self) -> httpx.Request:
126+
"""Build token exchange request for client_credentials grant with private_key_jwt auth.
127+
128+
This implements SEP-1046: OAuth Client Credentials Extension for MCP.
129+
Uses RFC 7523 Section 2.2 for client authentication via JWT assertion.
130+
"""
131+
if not self.context.client_info:
132+
raise OAuthFlowError("Missing client info") # pragma: no cover
133+
if not self.jwt_parameters:
134+
raise OAuthFlowError("Missing JWT parameters for client_credentials flow") # pragma: no cover
135+
if not self.context.oauth_metadata:
136+
raise OAuthTokenError("Missing OAuth metadata") # pragma: no cover
137+
138+
token_data: dict[str, Any] = {
139+
"grant_type": "client_credentials",
140+
}
141+
142+
# Add JWT client authentication (RFC 7523 Section 2.2)
143+
self._add_client_authentication_jwt(token_data=token_data)
144+
145+
if self.context.should_include_resource_param(self.context.protocol_version): # pragma: no branch
146+
token_data["resource"] = self.context.get_resource_url()
147+
148+
if self.context.client_metadata.scope: # pragma: no branch
149+
token_data["scope"] = self.context.client_metadata.scope
150+
151+
token_url = self._get_token_endpoint()
152+
return httpx.Request(
153+
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
154+
)
155+
120156
async def _exchange_token_jwt_bearer(self) -> httpx.Request:
121-
"""Build token exchange request for JWT bearer grant."""
157+
"""Build token exchange request for JWT bearer grant (RFC 7523 Section 2.1).
158+
159+
This is the legacy JWT bearer authorization grant where the JWT itself
160+
is the authorization grant, not client authentication.
161+
"""
122162
if not self.context.client_info:
123163
raise OAuthFlowError("Missing client info") # pragma: no cover
124164
if not self.jwt_parameters:

0 commit comments

Comments
 (0)