Skip to content
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
results/
lefthook-local.yml
dist
dist/
.vscode/
150 changes: 139 additions & 11 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,26 @@
* consolidating all the individual test clients into one.
*/

import { fileURLToPath } from 'url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
ClientCredentialsProvider,
PrivateKeyJwtProvider
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js';
import { logger } from './helpers/logger.js';

/**
* Fixed client metadata URL for CIMD conformance tests.
* When server supports client_id_metadata_document_supported, this URL
* will be used as the client_id instead of doing dynamic registration.
*/
const CIMD_CLIENT_METADATA_URL =
'https://conformance-test.local/client-metadata.json';

// Scenario handler type
type ScenarioHandler = (serverUrl: string) => Promise<void>;

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

/**
* Get a scenario handler by name.
* Returns undefined if no handler is registered for the scenario.
*/
export function getHandler(scenarioName: string): ScenarioHandler | undefined {
return scenarioHandlers[scenarioName];
}

// ============================================================================
// Basic scenarios (initialize, tools-call)
// ============================================================================
Expand Down Expand Up @@ -72,7 +94,9 @@ async function runAuthClient(serverUrl: string): Promise<void> {

const oauthFetch = withOAuthRetry(
'test-auth-client',
new URL(serverUrl)
new URL(serverUrl),
handle401,
CIMD_CLIENT_METADATA_URL
)(fetch);

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

// Register all auth scenarios that should use the well-behaved auth client
// Register all auth scenarios that use the well-behaved OAuth auth client
registerScenarios(
[
// Basic auth scenarios
'auth/basic-cimd',
'auth/basic-dcr',
'auth/basic-metadata-var1',
'auth/basic-metadata-var2',
'auth/basic-metadata-var3',
// Metadata discovery scenarios
'auth/metadata-default',
'auth/metadata-var1',
'auth/metadata-var2',
'auth/metadata-var3',
// Backcompat scenarios
'auth/2025-03-26-oauth-metadata-backcompat',
'auth/2025-03-26-oauth-endpoint-fallback',
// Scope handling scenarios
'auth/scope-from-www-authenticate',
'auth/scope-from-scopes-supported',
'auth/scope-omitted-when-undefined',
'auth/scope-step-up'
'auth/scope-step-up',
'auth/scope-retry-limit',
// Token endpoint auth method scenarios
'auth/token-endpoint-auth-basic',
'auth/token-endpoint-auth-post',
'auth/token-endpoint-auth-none'
],
runAuthClient
);
Expand Down Expand Up @@ -175,6 +210,96 @@ async function runElicitationDefaultsClient(serverUrl: string): Promise<void> {

registerScenario('elicitation-defaults', runElicitationDefaultsClient);

// ============================================================================
// Client Credentials scenarios
// ============================================================================

/**
* Parse the conformance context from MCP_CONFORMANCE_CONTEXT env var.
*/
function parseContext() {
const raw = process.env.MCP_CONFORMANCE_CONTEXT;
if (!raw) {
throw new Error('MCP_CONFORMANCE_CONTEXT not set');
}
return ClientConformanceContextSchema.parse(JSON.parse(raw));
}

/**
* Client credentials with private_key_jwt authentication.
*/
export async function runClientCredentialsJwt(
serverUrl: string
): Promise<void> {
const ctx = parseContext();
if (ctx.name !== 'auth/client-credentials-jwt') {
throw new Error(`Expected jwt context, got ${ctx.name}`);
}

const provider = new PrivateKeyJwtProvider({
clientId: ctx.client_id,
privateKey: ctx.private_key_pem,
algorithm: ctx.signing_algorithm || 'ES256'
});

const client = new Client(
{ name: 'conformance-client-credentials-jwt', version: '1.0.0' },
{ capabilities: {} }
);

const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
authProvider: provider
});

await client.connect(transport);
logger.debug('Successfully connected with private_key_jwt auth');

await client.listTools();
logger.debug('Successfully listed tools');

await transport.close();
logger.debug('Connection closed successfully');
}

registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt);

/**
* Client credentials with client_secret_basic authentication.
*/
export async function runClientCredentialsBasic(
serverUrl: string
): Promise<void> {
const ctx = parseContext();
if (ctx.name !== 'auth/client-credentials-basic') {
throw new Error(`Expected basic context, got ${ctx.name}`);
}

const provider = new ClientCredentialsProvider({
clientId: ctx.client_id,
clientSecret: ctx.client_secret
});

const client = new Client(
{ name: 'conformance-client-credentials-basic', version: '1.0.0' },
{ capabilities: {} }
);

const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
authProvider: provider
});

await client.connect(transport);
logger.debug('Successfully connected with client_secret_basic auth');

await client.listTools();
logger.debug('Successfully listed tools');

await transport.close();
logger.debug('Connection closed successfully');
}

registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);

// ============================================================================
// Main entry point
// ============================================================================
Expand Down Expand Up @@ -216,7 +341,10 @@ async function main(): Promise<void> {
}
}

main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});
// Only run main when this file is executed directly, not when imported as a module
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});
}
6 changes: 5 additions & 1 deletion src/runner/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ async function executeClient(
const env = { ...process.env };
env.MCP_CONFORMANCE_SCENARIO = scenarioName;
if (context) {
env.MCP_CONFORMANCE_CONTEXT = JSON.stringify(context);
// Include scenario name in context for discriminated union parsing
env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({
name: scenarioName,
...context
});
}

return new Promise((resolve) => {
Expand Down
15 changes: 9 additions & 6 deletions src/scenarios/client/auth/client-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,6 @@ export class ClientCredentialsJwtScenario implements Scenario {
tokenEndpointAuthMethodsSupported: ['private_key_jwt'],
tokenEndpointAuthSigningAlgValuesSupported: ['ES256'],
onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => {
// Per RFC 7523bis, the audience MUST be the issuer identifier
const issuerUrl = authBaseUrl.endsWith('/')
? authBaseUrl
: `${authBaseUrl}/`;
if (grantType !== 'client_credentials') {
this.checks.push({
id: 'client-credentials-grant-type',
Expand Down Expand Up @@ -97,9 +93,16 @@ export class ClientCredentialsJwtScenario implements Scenario {

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

Expand Down
13 changes: 7 additions & 6 deletions src/scenarios/client/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,21 @@ import {
runClientAgainstScenario,
InlineClientRunner
} from './test_helpers/testClient';
import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test';
import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm';
import { runClient as noCimdClient } from '../../../../examples/clients/typescript/auth-test-no-cimd';
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope';
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes';
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403';
import { runClient as noRetryLimitClient } from '../../../../examples/clients/typescript/auth-test-no-retry-limit';
import { getHandler } from '../../../../examples/clients/typescript/everything-client';
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';

beforeAll(() => {
setLogLevel('error');
});

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

const allowClientErrorScenarios = new Set<string>([
Expand All @@ -36,7 +33,11 @@ describe('Client Auth Scenarios', () => {
// TODO: skip in a native way?
return;
}
const runner = new InlineClientRunner(goodClient);
const clientFn = getHandler(scenario.name);
if (!clientFn) {
throw new Error(`No handler registered for scenario: ${scenario.name}`);
}
const runner = new InlineClientRunner(clientFn);
await runClientAgainstScenario(runner, scenario.name, {
allowClientError: allowClientErrorScenarios.has(scenario.name)
});
Expand Down
14 changes: 14 additions & 0 deletions src/scenarios/client/auth/test_helpers/testClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ export async function runClientAgainstScenario(
const serverUrl = urls.serverUrl;

try {
// Set environment variables for inline clients
// These mirror what src/runner/client.ts does for spawned processes
process.env.MCP_CONFORMANCE_SCENARIO = scenarioName;
if (urls.context) {
process.env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({
name: scenarioName,
...urls.context
});
}

// Run the client
try {
await runner.run(serverUrl);
Expand Down Expand Up @@ -166,6 +176,10 @@ export async function runClientAgainstScenario(
}
}
} finally {
// Clean up environment variables
delete process.env.MCP_CONFORMANCE_SCENARIO;
delete process.env.MCP_CONFORMANCE_CONTEXT;

// Stop the scenario server
await scenario.stop();
}
Expand Down
25 changes: 25 additions & 0 deletions src/schemas/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { z } from 'zod';

/**
* Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT.
*
* Each variant includes a `name` field matching the scenario name to enable
* discriminated union parsing and type-safe access to scenario-specific fields.
*/
export const ClientConformanceContextSchema = z.discriminatedUnion('name', [
z.object({
name: z.literal('auth/client-credentials-jwt'),
client_id: z.string(),
private_key_pem: z.string(),
signing_algorithm: z.string().optional()
}),
z.object({
name: z.literal('auth/client-credentials-basic'),
client_id: z.string(),
client_secret: z.string()
})
]);

export type ClientConformanceContext = z.infer<
typeof ClientConformanceContextSchema
>;
Loading