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
This commit is contained in:
parent
a90896b7d3
commit
753f1240d1
7 changed files with 201 additions and 25 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
node_modules
|
||||
results/
|
||||
lefthook-local.yml
|
||||
dist
|
||||
dist/
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
@ -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<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), {
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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>([
|
||||
|
|
@ -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)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
25
src/schemas/context.ts
Normal file
25
src/schemas/context.ts
Normal file
|
|
@ -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…
Reference in a new issue