Skip to content

Commit 753f124

Browse files
authored
Add client credentials support to everything-client (#89)
* 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) - Remove client credentials scenarios from skip list * Accept both trailing slash forms for JWT audience per RFC 3986 Per RFC 3986, URLs with and without trailing slash are equivalent. The MCP spec recommends (SHOULD) the form without trailing slash, but since it's not a MUST, the conformance test should accept both forms for interoperability with clients like Pydantic that normalize URLs. * Remove .vscode from tracking and add to gitignore * Address review feedback - Remove unused issuerUrl variable and comment - Simplify audience array to exactly 2 values with clearer naming - Add comment explaining strip-then-add-back logic - Rename ConformanceContextSchema to ClientConformanceContextSchema * Remove dist from tracking and add to gitignore * Fix .gitignore formatting * Add getHandler to everything-client for cleaner test imports Instead of importing individual handlers, tests can now import getHandler and look up handlers by scenario name. * Use getHandler exclusively, remove goodClient import - Add CIMD support to runAuthClient in everything-client - Add auth fallback to getHandler for unregistered auth/* scenarios - Remove goodClient import from test file * Remove runAuthClient fallback, register all auth scenarios explicitly
1 parent a90896b commit 753f124

File tree

7 files changed

+201
-25
lines changed

7 files changed

+201
-25
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
node_modules
22
results/
33
lefthook-local.yml
4-
dist
4+
dist/
5+
.vscode/

examples/clients/typescript/everything-client.ts

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,26 @@
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';
18-
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
23+
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
24+
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js';
1925
import { logger } from './helpers/logger.js';
2026

27+
/**
28+
* Fixed client metadata URL for CIMD conformance tests.
29+
* When server supports client_id_metadata_document_supported, this URL
30+
* will be used as the client_id instead of doing dynamic registration.
31+
*/
32+
const CIMD_CLIENT_METADATA_URL =
33+
'https://conformance-test.local/client-metadata.json';
34+
2135
// Scenario handler type
2236
type ScenarioHandler = (serverUrl: string) => Promise<void>;
2337

@@ -36,6 +50,14 @@ function registerScenarios(names: string[], handler: ScenarioHandler): void {
3650
}
3751
}
3852

53+
/**
54+
* Get a scenario handler by name.
55+
* Returns undefined if no handler is registered for the scenario.
56+
*/
57+
export function getHandler(scenarioName: string): ScenarioHandler | undefined {
58+
return scenarioHandlers[scenarioName];
59+
}
60+
3961
// ============================================================================
4062
// Basic scenarios (initialize, tools-call)
4163
// ============================================================================
@@ -72,7 +94,9 @@ async function runAuthClient(serverUrl: string): Promise<void> {
7294

7395
const oauthFetch = withOAuthRetry(
7496
'test-auth-client',
75-
new URL(serverUrl)
97+
new URL(serverUrl),
98+
handle401,
99+
CIMD_CLIENT_METADATA_URL
76100
)(fetch);
77101

78102
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
@@ -92,19 +116,30 @@ async function runAuthClient(serverUrl: string): Promise<void> {
92116
logger.debug('Connection closed successfully');
93117
}
94118

95-
// Register all auth scenarios that should use the well-behaved auth client
119+
// Register all auth scenarios that use the well-behaved OAuth auth client
96120
registerScenarios(
97121
[
122+
// Basic auth scenarios
123+
'auth/basic-cimd',
98124
'auth/basic-dcr',
99-
'auth/basic-metadata-var1',
100-
'auth/basic-metadata-var2',
101-
'auth/basic-metadata-var3',
125+
// Metadata discovery scenarios
126+
'auth/metadata-default',
127+
'auth/metadata-var1',
128+
'auth/metadata-var2',
129+
'auth/metadata-var3',
130+
// Backcompat scenarios
102131
'auth/2025-03-26-oauth-metadata-backcompat',
103132
'auth/2025-03-26-oauth-endpoint-fallback',
133+
// Scope handling scenarios
104134
'auth/scope-from-www-authenticate',
105135
'auth/scope-from-scopes-supported',
106136
'auth/scope-omitted-when-undefined',
107-
'auth/scope-step-up'
137+
'auth/scope-step-up',
138+
'auth/scope-retry-limit',
139+
// Token endpoint auth method scenarios
140+
'auth/token-endpoint-auth-basic',
141+
'auth/token-endpoint-auth-post',
142+
'auth/token-endpoint-auth-none'
108143
],
109144
runAuthClient
110145
);
@@ -175,6 +210,96 @@ async function runElicitationDefaultsClient(serverUrl: string): Promise<void> {
175210

176211
registerScenario('elicitation-defaults', runElicitationDefaultsClient);
177212

213+
// ============================================================================
214+
// Client Credentials scenarios
215+
// ============================================================================
216+
217+
/**
218+
* Parse the conformance context from MCP_CONFORMANCE_CONTEXT env var.
219+
*/
220+
function parseContext() {
221+
const raw = process.env.MCP_CONFORMANCE_CONTEXT;
222+
if (!raw) {
223+
throw new Error('MCP_CONFORMANCE_CONTEXT not set');
224+
}
225+
return ClientConformanceContextSchema.parse(JSON.parse(raw));
226+
}
227+
228+
/**
229+
* Client credentials with private_key_jwt authentication.
230+
*/
231+
export async function runClientCredentialsJwt(
232+
serverUrl: string
233+
): Promise<void> {
234+
const ctx = parseContext();
235+
if (ctx.name !== 'auth/client-credentials-jwt') {
236+
throw new Error(`Expected jwt context, got ${ctx.name}`);
237+
}
238+
239+
const provider = new PrivateKeyJwtProvider({
240+
clientId: ctx.client_id,
241+
privateKey: ctx.private_key_pem,
242+
algorithm: ctx.signing_algorithm || 'ES256'
243+
});
244+
245+
const client = new Client(
246+
{ name: 'conformance-client-credentials-jwt', version: '1.0.0' },
247+
{ capabilities: {} }
248+
);
249+
250+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
251+
authProvider: provider
252+
});
253+
254+
await client.connect(transport);
255+
logger.debug('Successfully connected with private_key_jwt auth');
256+
257+
await client.listTools();
258+
logger.debug('Successfully listed tools');
259+
260+
await transport.close();
261+
logger.debug('Connection closed successfully');
262+
}
263+
264+
registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt);
265+
266+
/**
267+
* Client credentials with client_secret_basic authentication.
268+
*/
269+
export async function runClientCredentialsBasic(
270+
serverUrl: string
271+
): Promise<void> {
272+
const ctx = parseContext();
273+
if (ctx.name !== 'auth/client-credentials-basic') {
274+
throw new Error(`Expected basic context, got ${ctx.name}`);
275+
}
276+
277+
const provider = new ClientCredentialsProvider({
278+
clientId: ctx.client_id,
279+
clientSecret: ctx.client_secret
280+
});
281+
282+
const client = new Client(
283+
{ name: 'conformance-client-credentials-basic', version: '1.0.0' },
284+
{ capabilities: {} }
285+
);
286+
287+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
288+
authProvider: provider
289+
});
290+
291+
await client.connect(transport);
292+
logger.debug('Successfully connected with client_secret_basic auth');
293+
294+
await client.listTools();
295+
logger.debug('Successfully listed tools');
296+
297+
await transport.close();
298+
logger.debug('Connection closed successfully');
299+
}
300+
301+
registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);
302+
178303
// ============================================================================
179304
// Main entry point
180305
// ============================================================================
@@ -216,7 +341,10 @@ async function main(): Promise<void> {
216341
}
217342
}
218343

219-
main().catch((error) => {
220-
console.error('Unhandled error:', error);
221-
process.exit(1);
222-
});
344+
// Only run main when this file is executed directly, not when imported as a module
345+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
346+
main().catch((error) => {
347+
console.error('Unhandled error:', error);
348+
process.exit(1);
349+
});
350+
}

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: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,6 @@ export class ClientCredentialsJwtScenario implements Scenario {
5050
tokenEndpointAuthMethodsSupported: ['private_key_jwt'],
5151
tokenEndpointAuthSigningAlgValuesSupported: ['ES256'],
5252
onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => {
53-
// Per RFC 7523bis, the audience MUST be the issuer identifier
54-
const issuerUrl = authBaseUrl.endsWith('/')
55-
? authBaseUrl
56-
: `${authBaseUrl}/`;
5753
if (grantType !== 'client_credentials') {
5854
this.checks.push({
5955
id: 'client-credentials-grant-type',
@@ -97,9 +93,16 @@ export class ClientCredentialsJwtScenario implements Scenario {
9793

9894
// Verify JWT signature and claims using the generated public key
9995
try {
100-
// Per RFC 7523bis, audience MUST be the issuer identifier
96+
// Per RFC 7523bis, audience MUST be the issuer identifier.
97+
// Per RFC 3986, URLs with and without trailing slash are equivalent,
98+
// so we accept both forms for interoperability (e.g. Pydantic normalizes
99+
// URLs by adding trailing slashes).
100+
// Strip any trailing slashes first, then accept both the bare form
101+
// and the form with exactly one trailing slash.
102+
const withoutSlash = authBaseUrl.replace(/\/+$/, '');
103+
const withSlash = `${withoutSlash}/`;
101104
const { payload } = await jose.jwtVerify(clientAssertion, publicKey, {
102-
audience: issuerUrl,
105+
audience: [withoutSlash, withSlash],
103106
clockTolerance: 30
104107
});
105108

src/scenarios/client/auth/index.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,21 @@ import {
33
runClientAgainstScenario,
44
InlineClientRunner
55
} from './test_helpers/testClient';
6-
import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test';
76
import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm';
87
import { runClient as noCimdClient } from '../../../../examples/clients/typescript/auth-test-no-cimd';
98
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope';
109
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes';
1110
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403';
1211
import { runClient as noRetryLimitClient } from '../../../../examples/clients/typescript/auth-test-no-retry-limit';
12+
import { getHandler } from '../../../../examples/clients/typescript/everything-client';
1313
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
1414

1515
beforeAll(() => {
1616
setLogLevel('error');
1717
});
1818

1919
const skipScenarios = new Set<string>([
20-
// Client credentials scenarios require SDK support for client_credentials grant
21-
// Pending typescript-sdk implementation
22-
'auth/client-credentials-jwt',
23-
'auth/client-credentials-basic'
20+
// Add scenarios that should be skipped here
2421
]);
2522

2623
const allowClientErrorScenarios = new Set<string>([
@@ -36,7 +33,11 @@ describe('Client Auth Scenarios', () => {
3633
// TODO: skip in a native way?
3734
return;
3835
}
39-
const runner = new InlineClientRunner(goodClient);
36+
const clientFn = getHandler(scenario.name);
37+
if (!clientFn) {
38+
throw new Error(`No handler registered for scenario: ${scenario.name}`);
39+
}
40+
const runner = new InlineClientRunner(clientFn);
4041
await runClientAgainstScenario(runner, scenario.name, {
4142
allowClientError: allowClientErrorScenarios.has(scenario.name)
4243
});

src/scenarios/client/auth/test_helpers/testClient.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ export async function runClientAgainstScenario(
107107
const serverUrl = urls.serverUrl;
108108

109109
try {
110+
// Set environment variables for inline clients
111+
// These mirror what src/runner/client.ts does for spawned processes
112+
process.env.MCP_CONFORMANCE_SCENARIO = scenarioName;
113+
if (urls.context) {
114+
process.env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({
115+
name: scenarioName,
116+
...urls.context
117+
});
118+
}
119+
110120
// Run the client
111121
try {
112122
await runner.run(serverUrl);
@@ -166,6 +176,10 @@ export async function runClientAgainstScenario(
166176
}
167177
}
168178
} finally {
179+
// Clean up environment variables
180+
delete process.env.MCP_CONFORMANCE_SCENARIO;
181+
delete process.env.MCP_CONFORMANCE_CONTEXT;
182+
169183
// Stop the scenario server
170184
await scenario.stop();
171185
}

src/schemas/context.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { z } from 'zod';
2+
3+
/**
4+
* Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT.
5+
*
6+
* Each variant includes a `name` field matching the scenario name to enable
7+
* discriminated union parsing and type-safe access to scenario-specific fields.
8+
*/
9+
export const ClientConformanceContextSchema = z.discriminatedUnion('name', [
10+
z.object({
11+
name: z.literal('auth/client-credentials-jwt'),
12+
client_id: z.string(),
13+
private_key_pem: z.string(),
14+
signing_algorithm: z.string().optional()
15+
}),
16+
z.object({
17+
name: z.literal('auth/client-credentials-basic'),
18+
client_id: z.string(),
19+
client_secret: z.string()
20+
})
21+
]);
22+
23+
export type ClientConformanceContext = z.infer<
24+
typeof ClientConformanceContextSchema
25+
>;

0 commit comments

Comments
 (0)