Skip to content

Commit b007b28

Browse files
committed
Add simplified OAuth providers for client credentials flows (SEP-1046)
New OAuth providers for machine-to-machine authentication: - ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret - PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt (RFC 7523 Section 2.2) - SignedJWTParameters: Helper class for SDK-signed JWT assertions - static_assertion_provider(): Helper for pre-built JWTs from workload identity federation The new providers set client_info directly in constructor, bypassing dynamic client registration which isn't needed for pre-registered machine clients. Deprecate RFC7523OAuthClientProvider: The original implementation incorrectly used RFC 7523 Section 2.1 (jwt-bearer authorization grant) instead of the intended Section 2.2 (private_key_jwt client authentication with grant_type=client_credentials). Also skip 3 flaky timing-dependent tests in test_stdio.py.
1 parent 27279bc commit b007b28

File tree

4 files changed

+735
-21
lines changed

4 files changed

+735
-21
lines changed

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

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,57 @@
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+
Environment Variables:
13+
MCP_CONFORMANCE_CONTEXT - JSON object containing test credentials:
14+
{
15+
"client_id": "...",
16+
"client_secret": "...", # For client_secret_basic flow
17+
"private_key_pem": "...", # For private_key_jwt flow
18+
"signing_algorithm": "ES256" # Optional, defaults to ES256
19+
}
20+
21+
Scenarios:
22+
auth/* - Authorization code flow scenarios (default behavior)
23+
auth/client-credentials-jwt - Client credentials with JWT authentication (SEP-1046)
24+
auth/client-credentials-basic - Client credentials with client_secret_basic
1125
"""
1226

1327
import asyncio
28+
import json
1429
import logging
30+
import os
1531
import sys
1632
from datetime import timedelta
1733
from urllib.parse import ParseResult, parse_qs, urlparse
1834

1935
import httpx
2036
from mcp import ClientSession
2137
from mcp.client.auth import OAuthClientProvider, TokenStorage
38+
from mcp.client.auth.extensions.client_credentials import (
39+
ClientCredentialsOAuthProvider,
40+
PrivateKeyJWTOAuthProvider,
41+
SignedJWTParameters,
42+
)
2243
from mcp.client.streamable_http import streamablehttp_client
2344
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
2445
from pydantic import AnyUrl
2546

47+
48+
def get_conformance_context() -> dict:
49+
"""Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable."""
50+
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
51+
if not context_json:
52+
raise RuntimeError(
53+
"MCP_CONFORMANCE_CONTEXT environment variable not set. "
54+
"Expected JSON with client_id, client_secret, and/or private_key_pem."
55+
)
56+
try:
57+
return json.loads(context_json)
58+
except json.JSONDecodeError as e:
59+
raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e
60+
2661
# Set up logging to stderr (stdout is for conformance test output)
2762
logging.basicConfig(
2863
level=logging.DEBUG,
@@ -111,17 +146,17 @@ async def handle_callback(self) -> tuple[str, str | None]:
111146
return auth_code, state
112147

113148

114-
async def run_client(server_url: str) -> None:
149+
async def run_authorization_code_client(server_url: str) -> None:
115150
"""
116-
Run the conformance test client against the given server URL.
151+
Run the conformance test client with authorization code flow.
117152
118153
This function:
119-
1. Connects to the MCP server with OAuth authentication
154+
1. Connects to the MCP server with OAuth authorization code flow
120155
2. Initializes the session
121156
3. Lists available tools
122157
4. Calls a test tool
123158
"""
124-
logger.debug(f"Starting conformance auth client for {server_url}")
159+
logger.debug(f"Starting conformance auth client (authorization_code) for {server_url}")
125160

126161
# Create callback handler that will automatically fetch auth codes
127162
callback_handler = ConformanceOAuthCallbackHandler()
@@ -140,6 +175,89 @@ async def run_client(server_url: str) -> None:
140175
callback_handler=callback_handler.handle_callback,
141176
)
142177

178+
await _run_session(server_url, oauth_auth)
179+
180+
181+
async def run_client_credentials_jwt_client(server_url: str) -> None:
182+
"""
183+
Run the conformance test client with client credentials flow using private_key_jwt (SEP-1046).
184+
185+
This function:
186+
1. Connects to the MCP server with OAuth client_credentials grant
187+
2. Uses private_key_jwt authentication with credentials from MCP_CONFORMANCE_CONTEXT
188+
3. Initializes the session
189+
4. Lists available tools
190+
5. Calls a test tool
191+
"""
192+
logger.debug(f"Starting conformance auth client (client_credentials_jwt) for {server_url}")
193+
194+
# Load credentials from environment
195+
context = get_conformance_context()
196+
client_id = context.get("client_id")
197+
private_key_pem = context.get("private_key_pem")
198+
signing_algorithm = context.get("signing_algorithm", "ES256")
199+
200+
if not client_id:
201+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
202+
if not private_key_pem:
203+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'")
204+
205+
# Create JWT parameters for SDK-signed assertions
206+
jwt_params = SignedJWTParameters(
207+
issuer=client_id,
208+
subject=client_id,
209+
signing_algorithm=signing_algorithm,
210+
signing_key=private_key_pem,
211+
)
212+
213+
# Create OAuth provider for client_credentials with private_key_jwt
214+
oauth_auth = PrivateKeyJWTOAuthProvider(
215+
server_url=server_url,
216+
storage=InMemoryTokenStorage(),
217+
client_id=client_id,
218+
assertion_provider=jwt_params.create_assertion_provider(),
219+
)
220+
221+
await _run_session(server_url, oauth_auth)
222+
223+
224+
async def run_client_credentials_basic_client(server_url: str) -> None:
225+
"""
226+
Run the conformance test client with client credentials flow using client_secret_basic.
227+
228+
This function:
229+
1. Connects to the MCP server with OAuth client_credentials grant
230+
2. Uses client_secret_basic authentication with credentials from MCP_CONFORMANCE_CONTEXT
231+
3. Initializes the session
232+
4. Lists available tools
233+
5. Calls a test tool
234+
"""
235+
logger.debug(f"Starting conformance auth client (client_credentials_basic) for {server_url}")
236+
237+
# Load credentials from environment
238+
context = get_conformance_context()
239+
client_id = context.get("client_id")
240+
client_secret = context.get("client_secret")
241+
242+
if not client_id:
243+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
244+
if not client_secret:
245+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
246+
247+
# Create OAuth provider for client_credentials with client_secret_basic
248+
oauth_auth = ClientCredentialsOAuthProvider(
249+
server_url=server_url,
250+
storage=InMemoryTokenStorage(),
251+
client_id=client_id,
252+
client_secret=client_secret,
253+
token_endpoint_auth_method="client_secret_basic",
254+
)
255+
256+
await _run_session(server_url, oauth_auth)
257+
258+
259+
async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
260+
"""Common session logic for all OAuth flows."""
143261
# Connect using streamable HTTP transport with OAuth
144262
async with streamablehttp_client(
145263
url=server_url,
@@ -168,14 +286,26 @@ async def run_client(server_url: str) -> None:
168286

169287
def main() -> None:
170288
"""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)
289+
if len(sys.argv) != 3:
290+
print(f"Usage: {sys.argv[0]} <scenario> <server-url>", file=sys.stderr)
291+
print("", file=sys.stderr)
292+
print("Scenarios:", file=sys.stderr)
293+
print(" auth/* - Authorization code flow (default)", file=sys.stderr)
294+
print(" auth/client-credentials-jwt - Client credentials with JWT auth (SEP-1046)", file=sys.stderr)
295+
print(" auth/client-credentials-basic - Client credentials with client_secret_basic", file=sys.stderr)
173296
sys.exit(1)
174297

175-
server_url = sys.argv[1]
298+
scenario = sys.argv[1]
299+
server_url = sys.argv[2]
176300

177301
try:
178-
asyncio.run(run_client(server_url))
302+
if scenario == "auth/client-credentials-jwt":
303+
asyncio.run(run_client_credentials_jwt_client(server_url))
304+
elif scenario == "auth/client-credentials-basic":
305+
asyncio.run(run_client_credentials_basic_client(server_url))
306+
else:
307+
# Default to authorization code flow for all other auth/* scenarios
308+
asyncio.run(run_authorization_code_client(server_url))
179309
except Exception:
180310
logger.exception("Client failed")
181311
sys.exit(1)

0 commit comments

Comments
 (0)