Skip to content

Commit c8663c1

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) - Remove client credentials scenarios from skip list
1 parent a90896b commit c8663c1

File tree

6 files changed

+162
-13
lines changed

6 files changed

+162
-13
lines changed

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',

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,32 @@ import { runClient as ignoreScopeClient } from '../../../../examples/clients/typ
1010
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes';
1111
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403';
1212
import { runClient as noRetryLimitClient } from '../../../../examples/clients/typescript/auth-test-no-retry-limit';
13+
import {
14+
runClientCredentialsJwt,
15+
runClientCredentialsBasic
16+
} from '../../../../examples/clients/typescript/everything-client';
1317
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
1418

1519
beforeAll(() => {
1620
setLogLevel('error');
1721
});
1822

1923
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'
24+
// Add scenarios that should be skipped here
2425
]);
2526

2627
const allowClientErrorScenarios = new Set<string>([
2728
// Client is expected to give up (error) after limited retries, but check should pass
2829
'auth/scope-retry-limit'
2930
]);
3031

32+
// Map of scenario names to their specific client handlers
33+
const scenarioClientMap: Record<string, (serverUrl: string) => Promise<void>> =
34+
{
35+
'auth/client-credentials-jwt': runClientCredentialsJwt,
36+
'auth/client-credentials-basic': runClientCredentialsBasic
37+
};
38+
3139
describe('Client Auth Scenarios', () => {
3240
// Generate individual test for each auth scenario
3341
for (const scenario of authScenariosList) {
@@ -36,7 +44,9 @@ describe('Client Auth Scenarios', () => {
3644
// TODO: skip in a native way?
3745
return;
3846
}
39-
const runner = new InlineClientRunner(goodClient);
47+
// Use scenario-specific client if available, otherwise use goodClient
48+
const clientFn = scenarioClientMap[scenario.name] ?? goodClient;
49+
const runner = new InlineClientRunner(clientFn);
4050
await runClientAgainstScenario(runner, scenario.name, {
4151
allowClientError: allowClientErrorScenarios.has(scenario.name)
4252
});

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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { z } from 'zod';
2+
3+
/**
4+
* Schema for 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 ConformanceContextSchema = 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 ConformanceContext = z.infer<typeof ConformanceContextSchema>;

0 commit comments

Comments
 (0)