Skip to content

Commit 15a7731

Browse files
committed
Add client credentials support to everything-client
- Add ConformanceContextSchema with discriminated union for auth contexts - Add runClientCredentialsJwt and runClientCredentialsBasic handlers - Update runner to include scenario name in context for discriminated union parsing - Update test helpers to set env vars for inline client runners - Fix JWT audience validation to match SDK behavior (no trailing slash) - Add Python client example for testing client credentials flows - Remove client credentials scenarios from skip list
1 parent a90896b commit 15a7731

File tree

7 files changed

+386
-13
lines changed

7 files changed

+386
-13
lines changed
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Client credentials conformance test client.
4+
5+
This client handles the auth/client-credentials-jwt and auth/client-credentials-basic
6+
scenarios from the MCP conformance test suite.
7+
8+
Usage:
9+
MCP_CONFORMANCE_SCENARIO=auth/client-credentials-jwt \
10+
MCP_CONFORMANCE_CONTEXT='{"name":"auth/client-credentials-jwt","client_id":"...","private_key_pem":"...","signing_algorithm":"ES256"}' \
11+
python client_credentials_client.py http://localhost:12345/mcp
12+
"""
13+
14+
import asyncio
15+
import json
16+
import os
17+
import sys
18+
import time
19+
from uuid import uuid4
20+
21+
import httpx
22+
import jwt
23+
24+
25+
async def get_oauth_metadata(client: httpx.AsyncClient, server_url: str) -> dict:
26+
"""Fetch OAuth authorization server metadata."""
27+
from urllib.parse import urljoin, urlparse
28+
29+
parsed = urlparse(server_url)
30+
base_url = f"{parsed.scheme}://{parsed.netloc}"
31+
32+
# First try the protected resource metadata
33+
prm_url = urljoin(base_url, f"/.well-known/oauth-protected-resource{parsed.path}")
34+
resp = await client.get(prm_url)
35+
if resp.status_code == 200:
36+
prm = resp.json()
37+
auth_server = prm.get("authorization_servers", [base_url])[0]
38+
else:
39+
auth_server = base_url
40+
41+
# Fetch authorization server metadata
42+
metadata_url = urljoin(auth_server, "/.well-known/oauth-authorization-server")
43+
resp = await client.get(metadata_url)
44+
resp.raise_for_status()
45+
return resp.json()
46+
47+
48+
def create_jwt_assertion(
49+
client_id: str,
50+
private_key_pem: str,
51+
algorithm: str,
52+
audience: str,
53+
) -> str:
54+
"""Create a JWT client assertion."""
55+
now = int(time.time())
56+
claims = {
57+
"iss": client_id,
58+
"sub": client_id,
59+
"aud": audience,
60+
"exp": now + 300,
61+
"iat": now,
62+
"jti": str(uuid4()),
63+
}
64+
return jwt.encode(claims, private_key_pem, algorithm=algorithm)
65+
66+
67+
async def run_client_credentials_jwt(server_url: str, context: dict) -> None:
68+
"""Run client credentials flow with private_key_jwt authentication."""
69+
client_id = context["client_id"]
70+
private_key_pem = context["private_key_pem"]
71+
signing_algorithm = context.get("signing_algorithm", "ES256")
72+
73+
async with httpx.AsyncClient() as client:
74+
# Get OAuth metadata
75+
metadata = await get_oauth_metadata(client, server_url)
76+
token_endpoint = metadata["token_endpoint"]
77+
issuer = metadata["issuer"]
78+
79+
# Create JWT assertion
80+
assertion = create_jwt_assertion(
81+
client_id=client_id,
82+
private_key_pem=private_key_pem,
83+
algorithm=signing_algorithm,
84+
audience=issuer,
85+
)
86+
87+
# Request token
88+
token_response = await client.post(
89+
token_endpoint,
90+
data={
91+
"grant_type": "client_credentials",
92+
"client_assertion": assertion,
93+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
94+
},
95+
)
96+
token_response.raise_for_status()
97+
tokens = token_response.json()
98+
access_token = tokens["access_token"]
99+
100+
# Connect to MCP server
101+
mcp_headers = {
102+
"Authorization": f"Bearer {access_token}",
103+
"Content-Type": "application/json",
104+
"Accept": "application/json, text/event-stream",
105+
}
106+
107+
init_response = await client.post(
108+
server_url,
109+
headers=mcp_headers,
110+
json={
111+
"jsonrpc": "2.0",
112+
"id": 1,
113+
"method": "initialize",
114+
"params": {
115+
"protocolVersion": "2024-11-05",
116+
"capabilities": {},
117+
"clientInfo": {"name": "conformance-python-client", "version": "1.0.0"},
118+
},
119+
},
120+
)
121+
init_response.raise_for_status()
122+
123+
# List tools
124+
tools_response = await client.post(
125+
server_url,
126+
headers=mcp_headers,
127+
json={
128+
"jsonrpc": "2.0",
129+
"id": 2,
130+
"method": "tools/list",
131+
"params": {},
132+
},
133+
)
134+
tools_response.raise_for_status()
135+
136+
print("Successfully connected with private_key_jwt auth", file=sys.stderr)
137+
138+
139+
async def run_client_credentials_basic(server_url: str, context: dict) -> None:
140+
"""Run client credentials flow with client_secret_basic authentication."""
141+
client_id = context["client_id"]
142+
client_secret = context["client_secret"]
143+
144+
async with httpx.AsyncClient() as client:
145+
# Get OAuth metadata
146+
metadata = await get_oauth_metadata(client, server_url)
147+
token_endpoint = metadata["token_endpoint"]
148+
149+
# Request token with Basic auth
150+
token_response = await client.post(
151+
token_endpoint,
152+
auth=(client_id, client_secret),
153+
data={"grant_type": "client_credentials"},
154+
)
155+
token_response.raise_for_status()
156+
tokens = token_response.json()
157+
access_token = tokens["access_token"]
158+
159+
# Connect to MCP server
160+
mcp_headers = {
161+
"Authorization": f"Bearer {access_token}",
162+
"Content-Type": "application/json",
163+
"Accept": "application/json, text/event-stream",
164+
}
165+
166+
init_response = await client.post(
167+
server_url,
168+
headers=mcp_headers,
169+
json={
170+
"jsonrpc": "2.0",
171+
"id": 1,
172+
"method": "initialize",
173+
"params": {
174+
"protocolVersion": "2024-11-05",
175+
"capabilities": {},
176+
"clientInfo": {"name": "conformance-python-client", "version": "1.0.0"},
177+
},
178+
},
179+
)
180+
init_response.raise_for_status()
181+
182+
# List tools
183+
tools_response = await client.post(
184+
server_url,
185+
headers=mcp_headers,
186+
json={
187+
"jsonrpc": "2.0",
188+
"id": 2,
189+
"method": "tools/list",
190+
"params": {},
191+
},
192+
)
193+
tools_response.raise_for_status()
194+
195+
print("Successfully connected with client_secret_basic auth", file=sys.stderr)
196+
197+
198+
async def main() -> None:
199+
"""Main entry point."""
200+
if len(sys.argv) < 2:
201+
print("Usage: client_credentials_client.py <server-url>", file=sys.stderr)
202+
sys.exit(1)
203+
204+
server_url = sys.argv[1]
205+
206+
context_str = os.environ.get("MCP_CONFORMANCE_CONTEXT")
207+
if not context_str:
208+
print("MCP_CONFORMANCE_CONTEXT not set", file=sys.stderr)
209+
sys.exit(1)
210+
211+
context = json.loads(context_str)
212+
scenario_name = context.get("name")
213+
214+
if scenario_name == "auth/client-credentials-jwt":
215+
await run_client_credentials_jwt(server_url, context)
216+
elif scenario_name == "auth/client-credentials-basic":
217+
await run_client_credentials_basic(server_url, context)
218+
else:
219+
print(f"Unknown scenario: {scenario_name}", file=sys.stderr)
220+
sys.exit(1)
221+
222+
223+
if __name__ == "__main__":
224+
asyncio.run(main())

examples/clients/typescript/everything-client.ts

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@
1212
* consolidating all the individual test clients into one.
1313
*/
1414

15+
import { fileURLToPath } from 'url';
1516
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
1617
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
18+
import {
19+
ClientCredentialsProvider,
20+
PrivateKeyJwtProvider
21+
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
1722
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
23+
import { ConformanceContextSchema } from '../../../src/schemas/context.js';
1824
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
1925
import { logger } from './helpers/logger.js';
2026

@@ -175,6 +181,96 @@ async function runElicitationDefaultsClient(serverUrl: string): Promise<void> {
175181

176182
registerScenario('elicitation-defaults', runElicitationDefaultsClient);
177183

184+
// ============================================================================
185+
// Client Credentials scenarios
186+
// ============================================================================
187+
188+
/**
189+
* Parse the conformance context from MCP_CONFORMANCE_CONTEXT env var.
190+
*/
191+
function parseContext() {
192+
const raw = process.env.MCP_CONFORMANCE_CONTEXT;
193+
if (!raw) {
194+
throw new Error('MCP_CONFORMANCE_CONTEXT not set');
195+
}
196+
return ConformanceContextSchema.parse(JSON.parse(raw));
197+
}
198+
199+
/**
200+
* Client credentials with private_key_jwt authentication.
201+
*/
202+
export async function runClientCredentialsJwt(
203+
serverUrl: string
204+
): Promise<void> {
205+
const ctx = parseContext();
206+
if (ctx.name !== 'auth/client-credentials-jwt') {
207+
throw new Error(`Expected jwt context, got ${ctx.name}`);
208+
}
209+
210+
const provider = new PrivateKeyJwtProvider({
211+
clientId: ctx.client_id,
212+
privateKey: ctx.private_key_pem,
213+
algorithm: ctx.signing_algorithm || 'ES256'
214+
});
215+
216+
const client = new Client(
217+
{ name: 'conformance-client-credentials-jwt', version: '1.0.0' },
218+
{ capabilities: {} }
219+
);
220+
221+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
222+
authProvider: provider
223+
});
224+
225+
await client.connect(transport);
226+
logger.debug('Successfully connected with private_key_jwt auth');
227+
228+
await client.listTools();
229+
logger.debug('Successfully listed tools');
230+
231+
await transport.close();
232+
logger.debug('Connection closed successfully');
233+
}
234+
235+
registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt);
236+
237+
/**
238+
* Client credentials with client_secret_basic authentication.
239+
*/
240+
export async function runClientCredentialsBasic(
241+
serverUrl: string
242+
): Promise<void> {
243+
const ctx = parseContext();
244+
if (ctx.name !== 'auth/client-credentials-basic') {
245+
throw new Error(`Expected basic context, got ${ctx.name}`);
246+
}
247+
248+
const provider = new ClientCredentialsProvider({
249+
clientId: ctx.client_id,
250+
clientSecret: ctx.client_secret
251+
});
252+
253+
const client = new Client(
254+
{ name: 'conformance-client-credentials-basic', version: '1.0.0' },
255+
{ capabilities: {} }
256+
);
257+
258+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
259+
authProvider: provider
260+
});
261+
262+
await client.connect(transport);
263+
logger.debug('Successfully connected with client_secret_basic auth');
264+
265+
await client.listTools();
266+
logger.debug('Successfully listed tools');
267+
268+
await transport.close();
269+
logger.debug('Connection closed successfully');
270+
}
271+
272+
registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);
273+
178274
// ============================================================================
179275
// Main entry point
180276
// ============================================================================
@@ -216,7 +312,10 @@ async function main(): Promise<void> {
216312
}
217313
}
218314

219-
main().catch((error) => {
220-
console.error('Unhandled error:', error);
221-
process.exit(1);
222-
});
315+
// Only run main when this file is executed directly, not when imported as a module
316+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
317+
main().catch((error) => {
318+
console.error('Unhandled error:', error);
319+
process.exit(1);
320+
});
321+
}

src/runner/client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ async function executeClient(
3535
const env = { ...process.env };
3636
env.MCP_CONFORMANCE_SCENARIO = scenarioName;
3737
if (context) {
38-
env.MCP_CONFORMANCE_CONTEXT = JSON.stringify(context);
38+
// Include scenario name in context for discriminated union parsing
39+
env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({
40+
name: scenarioName,
41+
...context
42+
});
3943
}
4044

4145
return new Promise((resolve) => {

src/scenarios/client/auth/client-credentials.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,8 @@ export class ClientCredentialsJwtScenario implements Scenario {
5151
tokenEndpointAuthSigningAlgValuesSupported: ['ES256'],
5252
onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => {
5353
// Per RFC 7523bis, the audience MUST be the issuer identifier
54-
const issuerUrl = authBaseUrl.endsWith('/')
55-
? authBaseUrl
56-
: `${authBaseUrl}/`;
54+
// The SDK uses metadata.issuer as audience, which matches authBaseUrl
55+
const issuerUrl = authBaseUrl;
5756
if (grantType !== 'client_credentials') {
5857
this.checks.push({
5958
id: 'client-credentials-grant-type',

0 commit comments

Comments
 (0)