diff --git a/.gitignore b/.gitignore index 4a1642b..5c745fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules results/ lefthook-local.yml -dist \ No newline at end of file +dist/ +.vscode/ diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 464eb7a..4491fe6 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -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; @@ -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) // ============================================================================ @@ -72,7 +94,9 @@ async function runAuthClient(serverUrl: string): Promise { 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), { @@ -92,19 +116,30 @@ async function runAuthClient(serverUrl: string): Promise { 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 ); @@ -175,6 +210,96 @@ async function runElicitationDefaultsClient(serverUrl: string): Promise { 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 { + 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 { + 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 // ============================================================================ @@ -216,7 +341,10 @@ async function main(): Promise { } } -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); + }); +} diff --git a/src/runner/client.ts b/src/runner/client.ts index dcb005f..7db5461 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -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) => { diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index df13384..b82b5e2 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -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', @@ -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 }); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 803ecf7..6dcc020 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -3,13 +3,13 @@ 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(() => { @@ -17,10 +17,7 @@ beforeAll(() => { }); const skipScenarios = new Set([ - // 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([ @@ -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) }); diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index 45f4e6d..0dbc8d8 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -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); @@ -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(); } diff --git a/src/schemas/context.ts b/src/schemas/context.ts new file mode 100644 index 0000000..d7ea2a2 --- /dev/null +++ b/src/schemas/context.ts @@ -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 +>;