Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions examples/clients/typescript/auth-test-attempts-dcr.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <server-url>');
70 changes: 69 additions & 1 deletion examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -300,6 +305,69 @@ 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<void> {
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,
redirect_uris: ['http://localhost:3000/callback']
});

// 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
// ============================================================================
Expand Down
17 changes: 16 additions & 1 deletion examples/clients/typescript/helpers/withOAuthRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const handle401 = async (
}
}
};

/**
* Creates a fetch wrapper that handles OAuth authentication with retry logic.
*
Expand All @@ -53,8 +54,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 = (
Expand All @@ -71,6 +74,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,
Expand Down
7 changes: 6 additions & 1 deletion src/scenarios/client/auth/helpers/createAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,6 +67,7 @@ export function createAuthServer(
tokenEndpointAuthMethodsSupported = ['none'],
tokenEndpointAuthSigningAlgValuesSupported,
clientIdMetadataDocumentSupported,
disableDynamicRegistration = false,
tokenVerifier,
onTokenRequest,
onAuthorizationRequest,
Expand Down Expand Up @@ -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'],
Expand Down
4 changes: 3 additions & 1 deletion src/scenarios/client/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand All @@ -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)
Expand Down
151 changes: 151 additions & 0 deletions src/scenarios/client/auth/pre-registration.ts
Original file line number Diff line number Diff line change
@@ -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<ScenarioUrls> {
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;
}
}
4 changes: 4 additions & 0 deletions src/scenarios/client/auth/spec-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
};
5 changes: 5 additions & 0 deletions src/schemas/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
]);

Expand Down
Loading