From b5794838943b7e8fb8c2695832d2e70ce2729519 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 10:13:38 +0000 Subject: [PATCH 1/3] feat: add pre-registration conformance test Add conformance test for OAuth pre-registration flow where the server does NOT support Dynamic Client Registration (DCR) and clients must use pre-configured static credentials. This addresses issue #34 (Client Registration Methods) - specifically the pre-registration section that was previously not covered. Changes: - Add disableDynamicRegistration option to createAuthServer helper - Add auth/pre-registration scenario with pre-registered client creds - Add MCP_PREREGISTRATION spec reference - Add context schema for pre-registration credentials - Add withOAuthRetryWithProvider helper for pre-configured providers - Add negative test client (auth-test-attempts-dcr.ts) that ignores pre-registered credentials and fails The scenario verifies that when a server does not advertise registration_endpoint in its OAuth metadata, compliant clients use pre-registered credentials passed via context instead of attempting DCR. Closes #34 --- .../typescript/auth-test-attempts-dcr.ts | 50 ++++++ .../clients/typescript/everything-client.ts | 69 +++++++- .../typescript/helpers/withOAuthRetry.ts | 25 ++- .../client/auth/helpers/createAuthServer.ts | 7 +- src/scenarios/client/auth/index.ts | 4 +- src/scenarios/client/auth/pre-registration.ts | 151 ++++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 4 + src/schemas/context.ts | 5 + 8 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 examples/clients/typescript/auth-test-attempts-dcr.ts create mode 100644 src/scenarios/client/auth/pre-registration.ts diff --git a/examples/clients/typescript/auth-test-attempts-dcr.ts b/examples/clients/typescript/auth-test-attempts-dcr.ts new file mode 100644 index 0000000..f9a6510 --- /dev/null +++ b/examples/clients/typescript/auth-test-attempts-dcr.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +/** + * Non-compliant client that ignores pre-registered credentials and attempts DCR. + * + * This client intentionally ignores the client_id and client_secret passed via + * MCP_CONFORMANCE_CONTEXT and instead attempts to do Dynamic Client Registration. + * When run against a server that does not support DCR (no registration_endpoint), + * this client will fail. + * + * Used to test that conformance checks detect clients that don't properly + * use pre-registered credentials when server doesn't support DCR. + */ +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client-attempts-dcr', version: '1.0.0' }, + { capabilities: {} } + ); + + // Non-compliant: ignores pre-registered credentials from context + // and creates a fresh provider that will attempt DCR + const oauthFetch = withOAuthRetry( + 'test-auth-client-attempts-dcr', + new URL(serverUrl) + )(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Connected to MCP server (attempted DCR instead of pre-reg)'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +runAsCli(runClient, import.meta.url, 'auth-test-attempts-dcr '); diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 4491fe6..fa339bb 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -21,7 +21,12 @@ import { } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; -import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js'; +import { + withOAuthRetry, + withOAuthRetryWithProvider, + handle401 +} from './helpers/withOAuthRetry.js'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; import { logger } from './helpers/logger.js'; /** @@ -300,6 +305,68 @@ export async function runClientCredentialsBasic( registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); +// ============================================================================ +// Pre-registration scenario +// ============================================================================ + +/** + * Pre-registration: client uses pre-registered credentials (no DCR). + * + * Server does not advertise registration_endpoint, so client must use + * pre-configured client_id and client_secret passed via context. + */ +export async function runPreRegistration(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/pre-registration') { + throw new Error(`Expected pre-registration context, got ${ctx.name}`); + } + + const client = new Client( + { name: 'conformance-pre-registration', version: '1.0.0' }, + { capabilities: {} } + ); + + // Create provider with pre-registered credentials + const provider = new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: 'conformance-pre-registration', + redirect_uris: ['http://localhost:3000/callback'] + } + ); + + // Pre-set the client information so the SDK won't attempt DCR + provider.saveClientInformation({ + client_id: ctx.client_id, + client_secret: ctx.client_secret + }); + + // Use the provider-based middleware + const oauthFetch = withOAuthRetryWithProvider( + provider, + new URL(serverUrl), + handle401 + )(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Successfully connected with pre-registered credentials'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/pre-registration', runPreRegistration); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index 497f4a4..98b1d97 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -45,6 +45,15 @@ export const handle401 = async ( } } }; +export interface WithOAuthRetryOptions { + clientName: string; + baseUrl?: string | URL; + handle401Fn?: typeof handle401; + clientMetadataUrl?: string; + /** Pre-configured provider to use instead of creating a new one */ + provider?: ConformanceOAuthProvider; +} + /** * Creates a fetch wrapper that handles OAuth authentication with retry logic. * @@ -53,8 +62,10 @@ export const handle401 = async ( * - Does not throw UnauthorizedError on redirect, but instead retries * - Calls next() instead of throwing for redirect-based auth * - * @param provider - OAuth client provider for authentication + * @param clientName - Client name for OAuth registration * @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain) + * @param handle401Fn - Custom 401 handler function + * @param clientMetadataUrl - Optional CIMD URL for URL-based client IDs * @returns A fetch middleware function */ export const withOAuthRetry = ( @@ -71,6 +82,18 @@ export const withOAuthRetry = ( }, clientMetadataUrl ); + return withOAuthRetryWithProvider(provider, baseUrl, handle401Fn); +}; + +/** + * Creates a fetch wrapper using a pre-configured OAuth provider. + * Use this when you need to pre-set client credentials (e.g., for pre-registration tests). + */ +export const withOAuthRetryWithProvider = ( + provider: ConformanceOAuthProvider, + baseUrl?: string | URL, + handle401Fn: typeof handle401 = handle401 +): Middleware => { return (next: FetchLike) => { return async ( input: string | URL, diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 1071828..9d06cfa 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -25,6 +25,8 @@ export interface AuthServerOptions { tokenEndpointAuthMethodsSupported?: string[]; tokenEndpointAuthSigningAlgValuesSupported?: string[]; clientIdMetadataDocumentSupported?: boolean; + /** Set to true to NOT advertise registration_endpoint (for pre-registration tests) */ + disableDynamicRegistration?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -65,6 +67,7 @@ export function createAuthServer( tokenEndpointAuthMethodsSupported = ['none'], tokenEndpointAuthSigningAlgValuesSupported, clientIdMetadataDocumentSupported, + disableDynamicRegistration = false, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -114,7 +117,9 @@ export function createAuthServer( issuer: getAuthBaseUrl(), authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, - registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`, + ...(!disableDynamicRegistration && { + registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}` + }), response_types_supported: ['code'], grant_types_supported: grantTypesSupported, code_challenge_methods_supported: ['S256'], diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 805781a..91da1ef 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -21,6 +21,7 @@ import { ClientCredentialsJwtScenario, ClientCredentialsBasicScenario } from './client-credentials'; +import { PreRegistrationScenario } from './pre-registration'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -35,7 +36,8 @@ export const authScenariosList: Scenario[] = [ new ScopeRetryLimitScenario(), new ClientSecretBasicAuthScenario(), new ClientSecretPostAuthScenario(), - new PublicClientAuthScenario() + new PublicClientAuthScenario(), + new PreRegistrationScenario() ]; // Extension scenarios (optional for tier 1 - protocol extensions) diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts new file mode 100644 index 0000000..0d95e33 --- /dev/null +++ b/src/scenarios/client/auth/pre-registration.ts @@ -0,0 +1,151 @@ +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; +import { createAuthServer } from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { SpecReferences } from './spec-references'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier'; + +const PRE_REGISTERED_CLIENT_ID = 'pre-registered-client'; +const PRE_REGISTERED_CLIENT_SECRET = 'pre-registered-secret'; + +/** + * Scenario: Pre-registration (static client credentials) + * + * Tests OAuth flow where the server does NOT support Dynamic Client Registration. + * Clients must use pre-registered credentials passed via context. + * + * This tests the pre-registration approach described in the MCP spec: + * https://modelcontextprotocol.io/specification/draft/basic/authorization#preregistration + */ +export class PreRegistrationScenario implements Scenario { + name = 'auth/pre-registration'; + description = + 'Tests OAuth flow with pre-registered client credentials. Server does not support DCR.'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + disableDynamicRegistration: true, + tokenEndpointAuthMethodsSupported: ['client_secret_basic'], + onTokenRequest: ({ authorizationHeader, timestamp }) => { + // Verify client used pre-registered credentials via Basic auth + if (!authorizationHeader?.startsWith('Basic ')) { + this.checks.push({ + id: 'pre-registration-auth', + name: 'Pre-registration authentication', + description: + 'Client did not use Basic authentication with pre-registered credentials', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.MCP_PREREGISTRATION] + }); + return { + error: 'invalid_client', + errorDescription: 'Missing or invalid Authorization header', + statusCode: 401 + }; + } + + const base64Credentials = authorizationHeader.slice(6); + const credentials = Buffer.from(base64Credentials, 'base64').toString( + 'utf-8' + ); + const [clientId, clientSecret] = credentials.split(':'); + + if ( + clientId !== PRE_REGISTERED_CLIENT_ID || + clientSecret !== PRE_REGISTERED_CLIENT_SECRET + ) { + this.checks.push({ + id: 'pre-registration-auth', + name: 'Pre-registration authentication', + description: `Client used incorrect pre-registered credentials. Expected client_id '${PRE_REGISTERED_CLIENT_ID}', got '${clientId}'`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.MCP_PREREGISTRATION], + details: { + expectedClientId: PRE_REGISTERED_CLIENT_ID, + actualClientId: clientId + } + }); + return { + error: 'invalid_client', + errorDescription: 'Invalid pre-registered credentials', + statusCode: 401 + }; + } + + // Success - client used correct pre-registered credentials + this.checks.push({ + id: 'pre-registration-auth', + name: 'Pre-registration authentication', + description: + 'Client correctly used pre-registered credentials when server does not support DCR', + status: 'SUCCESS', + timestamp, + specReferences: [SpecReferences.MCP_PREREGISTRATION], + details: { clientId } + }); + + return { + token: `test-token-prereg-${Date.now()}`, + scopes: [] + }; + } + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [], + tokenVerifier + } + ); + + await this.server.start(app); + + return { + serverUrl: `${this.server.getUrl()}/mcp`, + context: { + client_id: PRE_REGISTERED_CLIENT_ID, + client_secret: PRE_REGISTERED_CLIENT_SECRET + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the pre-registration check + const hasPreRegCheck = this.checks.some( + (c) => c.id === 'pre-registration-auth' + ); + if (!hasPreRegCheck) { + this.checks.push({ + id: 'pre-registration-auth', + name: 'Pre-registration authentication', + description: 'Client did not make a token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_PREREGISTRATION] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 52a08ca..131a60f 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -72,5 +72,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { SEP_1046_CLIENT_CREDENTIALS: { id: 'SEP-1046-Client-Credentials', url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx' + }, + MCP_PREREGISTRATION: { + id: 'MCP-Preregistration', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration' } }; diff --git a/src/schemas/context.ts b/src/schemas/context.ts index d7ea2a2..2a8a907 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -17,6 +17,11 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ name: z.literal('auth/client-credentials-basic'), client_id: z.string(), client_secret: z.string() + }), + z.object({ + name: z.literal('auth/pre-registration'), + client_id: z.string(), + client_secret: z.string() }) ]); From b342e0d6ce1c3f879a79ee813b5b8e09d02e69ea Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 11:21:22 +0000 Subject: [PATCH 2/3] fix: add redirect_uris to saveClientInformation call --- examples/clients/typescript/everything-client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index fa339bb..3e6a07a 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -338,7 +338,8 @@ export async function runPreRegistration(serverUrl: string): Promise { // Pre-set the client information so the SDK won't attempt DCR provider.saveClientInformation({ client_id: ctx.client_id, - client_secret: ctx.client_secret + client_secret: ctx.client_secret, + redirect_uris: ['http://localhost:3000/callback'] }); // Use the provider-based middleware From a8bbf89a51113bbca8bdb3b0383ee088a460cf3e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 11:38:49 +0000 Subject: [PATCH 3/3] chore: remove unused WithOAuthRetryOptions interface --- examples/clients/typescript/helpers/withOAuthRetry.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index 98b1d97..429ca53 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -45,14 +45,6 @@ export const handle401 = async ( } } }; -export interface WithOAuthRetryOptions { - clientName: string; - baseUrl?: string | URL; - handle401Fn?: typeof handle401; - clientMetadataUrl?: string; - /** Pre-configured provider to use instead of creating a new one */ - provider?: ConformanceOAuthProvider; -} /** * Creates a fetch wrapper that handles OAuth authentication with retry logic.