Skip to content

Commit 245e44e

Browse files
authored
feat: add pre-registration conformance test (#120)
* 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 * fix: add redirect_uris to saveClientInformation call * chore: remove unused WithOAuthRetryOptions interface
1 parent 5dca74b commit 245e44e

8 files changed

Lines changed: 304 additions & 4 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/usr/bin/env node
2+
3+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5+
import { withOAuthRetry } from './helpers/withOAuthRetry';
6+
import { runAsCli } from './helpers/cliRunner';
7+
import { logger } from './helpers/logger';
8+
9+
/**
10+
* Non-compliant client that ignores pre-registered credentials and attempts DCR.
11+
*
12+
* This client intentionally ignores the client_id and client_secret passed via
13+
* MCP_CONFORMANCE_CONTEXT and instead attempts to do Dynamic Client Registration.
14+
* When run against a server that does not support DCR (no registration_endpoint),
15+
* this client will fail.
16+
*
17+
* Used to test that conformance checks detect clients that don't properly
18+
* use pre-registered credentials when server doesn't support DCR.
19+
*/
20+
export async function runClient(serverUrl: string): Promise<void> {
21+
const client = new Client(
22+
{ name: 'test-auth-client-attempts-dcr', version: '1.0.0' },
23+
{ capabilities: {} }
24+
);
25+
26+
// Non-compliant: ignores pre-registered credentials from context
27+
// and creates a fresh provider that will attempt DCR
28+
const oauthFetch = withOAuthRetry(
29+
'test-auth-client-attempts-dcr',
30+
new URL(serverUrl)
31+
)(fetch);
32+
33+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
34+
fetch: oauthFetch
35+
});
36+
37+
await client.connect(transport);
38+
logger.debug('Connected to MCP server (attempted DCR instead of pre-reg)');
39+
40+
await client.listTools();
41+
logger.debug('Successfully listed tools');
42+
43+
await client.callTool({ name: 'test-tool', arguments: {} });
44+
logger.debug('Successfully called tool');
45+
46+
await transport.close();
47+
logger.debug('Connection closed successfully');
48+
}
49+
50+
runAsCli(runClient, import.meta.url, 'auth-test-attempts-dcr <server-url>');

examples/clients/typescript/everything-client.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ import {
2121
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
2222
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2323
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
24-
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js';
24+
import {
25+
withOAuthRetry,
26+
withOAuthRetryWithProvider,
27+
handle401
28+
} from './helpers/withOAuthRetry.js';
29+
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
2530
import { logger } from './helpers/logger.js';
2631

2732
/**
@@ -300,6 +305,69 @@ export async function runClientCredentialsBasic(
300305

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

308+
// ============================================================================
309+
// Pre-registration scenario
310+
// ============================================================================
311+
312+
/**
313+
* Pre-registration: client uses pre-registered credentials (no DCR).
314+
*
315+
* Server does not advertise registration_endpoint, so client must use
316+
* pre-configured client_id and client_secret passed via context.
317+
*/
318+
export async function runPreRegistration(serverUrl: string): Promise<void> {
319+
const ctx = parseContext();
320+
if (ctx.name !== 'auth/pre-registration') {
321+
throw new Error(`Expected pre-registration context, got ${ctx.name}`);
322+
}
323+
324+
const client = new Client(
325+
{ name: 'conformance-pre-registration', version: '1.0.0' },
326+
{ capabilities: {} }
327+
);
328+
329+
// Create provider with pre-registered credentials
330+
const provider = new ConformanceOAuthProvider(
331+
'http://localhost:3000/callback',
332+
{
333+
client_name: 'conformance-pre-registration',
334+
redirect_uris: ['http://localhost:3000/callback']
335+
}
336+
);
337+
338+
// Pre-set the client information so the SDK won't attempt DCR
339+
provider.saveClientInformation({
340+
client_id: ctx.client_id,
341+
client_secret: ctx.client_secret,
342+
redirect_uris: ['http://localhost:3000/callback']
343+
});
344+
345+
// Use the provider-based middleware
346+
const oauthFetch = withOAuthRetryWithProvider(
347+
provider,
348+
new URL(serverUrl),
349+
handle401
350+
)(fetch);
351+
352+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
353+
fetch: oauthFetch
354+
});
355+
356+
await client.connect(transport);
357+
logger.debug('Successfully connected with pre-registered credentials');
358+
359+
await client.listTools();
360+
logger.debug('Successfully listed tools');
361+
362+
await client.callTool({ name: 'test-tool', arguments: {} });
363+
logger.debug('Successfully called tool');
364+
365+
await transport.close();
366+
logger.debug('Connection closed successfully');
367+
}
368+
369+
registerScenario('auth/pre-registration', runPreRegistration);
370+
303371
// ============================================================================
304372
// Main entry point
305373
// ============================================================================

examples/clients/typescript/helpers/withOAuthRetry.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const handle401 = async (
4545
}
4646
}
4747
};
48+
4849
/**
4950
* Creates a fetch wrapper that handles OAuth authentication with retry logic.
5051
*
@@ -53,8 +54,10 @@ export const handle401 = async (
5354
* - Does not throw UnauthorizedError on redirect, but instead retries
5455
* - Calls next() instead of throwing for redirect-based auth
5556
*
56-
* @param provider - OAuth client provider for authentication
57+
* @param clientName - Client name for OAuth registration
5758
* @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain)
59+
* @param handle401Fn - Custom 401 handler function
60+
* @param clientMetadataUrl - Optional CIMD URL for URL-based client IDs
5861
* @returns A fetch middleware function
5962
*/
6063
export const withOAuthRetry = (
@@ -71,6 +74,18 @@ export const withOAuthRetry = (
7174
},
7275
clientMetadataUrl
7376
);
77+
return withOAuthRetryWithProvider(provider, baseUrl, handle401Fn);
78+
};
79+
80+
/**
81+
* Creates a fetch wrapper using a pre-configured OAuth provider.
82+
* Use this when you need to pre-set client credentials (e.g., for pre-registration tests).
83+
*/
84+
export const withOAuthRetryWithProvider = (
85+
provider: ConformanceOAuthProvider,
86+
baseUrl?: string | URL,
87+
handle401Fn: typeof handle401 = handle401
88+
): Middleware => {
7489
return (next: FetchLike) => {
7590
return async (
7691
input: string | URL,

src/scenarios/client/auth/helpers/createAuthServer.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface AuthServerOptions {
2525
tokenEndpointAuthMethodsSupported?: string[];
2626
tokenEndpointAuthSigningAlgValuesSupported?: string[];
2727
clientIdMetadataDocumentSupported?: boolean;
28+
/** Set to true to NOT advertise registration_endpoint (for pre-registration tests) */
29+
disableDynamicRegistration?: boolean;
2830
tokenVerifier?: MockTokenVerifier;
2931
onTokenRequest?: (requestData: {
3032
scope?: string;
@@ -65,6 +67,7 @@ export function createAuthServer(
6567
tokenEndpointAuthMethodsSupported = ['none'],
6668
tokenEndpointAuthSigningAlgValuesSupported,
6769
clientIdMetadataDocumentSupported,
70+
disableDynamicRegistration = false,
6871
tokenVerifier,
6972
onTokenRequest,
7073
onAuthorizationRequest,
@@ -114,7 +117,9 @@ export function createAuthServer(
114117
issuer: getAuthBaseUrl(),
115118
authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`,
116119
token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`,
117-
registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`,
120+
...(!disableDynamicRegistration && {
121+
registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`
122+
}),
118123
response_types_supported: ['code'],
119124
grant_types_supported: grantTypesSupported,
120125
code_challenge_methods_supported: ['S256'],

src/scenarios/client/auth/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
ClientCredentialsJwtScenario,
2222
ClientCredentialsBasicScenario
2323
} from './client-credentials';
24+
import { PreRegistrationScenario } from './pre-registration';
2425

2526
// Auth scenarios (required for tier 1)
2627
export const authScenariosList: Scenario[] = [
@@ -35,7 +36,8 @@ export const authScenariosList: Scenario[] = [
3536
new ScopeRetryLimitScenario(),
3637
new ClientSecretBasicAuthScenario(),
3738
new ClientSecretPostAuthScenario(),
38-
new PublicClientAuthScenario()
39+
new PublicClientAuthScenario(),
40+
new PreRegistrationScenario()
3941
];
4042

4143
// Extension scenarios (optional for tier 1 - protocol extensions)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types';
2+
import { createAuthServer } from './helpers/createAuthServer';
3+
import { createServer } from './helpers/createServer';
4+
import { ServerLifecycle } from './helpers/serverLifecycle';
5+
import { SpecReferences } from './spec-references';
6+
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
7+
8+
const PRE_REGISTERED_CLIENT_ID = 'pre-registered-client';
9+
const PRE_REGISTERED_CLIENT_SECRET = 'pre-registered-secret';
10+
11+
/**
12+
* Scenario: Pre-registration (static client credentials)
13+
*
14+
* Tests OAuth flow where the server does NOT support Dynamic Client Registration.
15+
* Clients must use pre-registered credentials passed via context.
16+
*
17+
* This tests the pre-registration approach described in the MCP spec:
18+
* https://modelcontextprotocol.io/specification/draft/basic/authorization#preregistration
19+
*/
20+
export class PreRegistrationScenario implements Scenario {
21+
name = 'auth/pre-registration';
22+
description =
23+
'Tests OAuth flow with pre-registered client credentials. Server does not support DCR.';
24+
25+
private authServer = new ServerLifecycle();
26+
private server = new ServerLifecycle();
27+
private checks: ConformanceCheck[] = [];
28+
29+
async start(): Promise<ScenarioUrls> {
30+
this.checks = [];
31+
const tokenVerifier = new MockTokenVerifier(this.checks, []);
32+
33+
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
34+
tokenVerifier,
35+
disableDynamicRegistration: true,
36+
tokenEndpointAuthMethodsSupported: ['client_secret_basic'],
37+
onTokenRequest: ({ authorizationHeader, timestamp }) => {
38+
// Verify client used pre-registered credentials via Basic auth
39+
if (!authorizationHeader?.startsWith('Basic ')) {
40+
this.checks.push({
41+
id: 'pre-registration-auth',
42+
name: 'Pre-registration authentication',
43+
description:
44+
'Client did not use Basic authentication with pre-registered credentials',
45+
status: 'FAILURE',
46+
timestamp,
47+
specReferences: [SpecReferences.MCP_PREREGISTRATION]
48+
});
49+
return {
50+
error: 'invalid_client',
51+
errorDescription: 'Missing or invalid Authorization header',
52+
statusCode: 401
53+
};
54+
}
55+
56+
const base64Credentials = authorizationHeader.slice(6);
57+
const credentials = Buffer.from(base64Credentials, 'base64').toString(
58+
'utf-8'
59+
);
60+
const [clientId, clientSecret] = credentials.split(':');
61+
62+
if (
63+
clientId !== PRE_REGISTERED_CLIENT_ID ||
64+
clientSecret !== PRE_REGISTERED_CLIENT_SECRET
65+
) {
66+
this.checks.push({
67+
id: 'pre-registration-auth',
68+
name: 'Pre-registration authentication',
69+
description: `Client used incorrect pre-registered credentials. Expected client_id '${PRE_REGISTERED_CLIENT_ID}', got '${clientId}'`,
70+
status: 'FAILURE',
71+
timestamp,
72+
specReferences: [SpecReferences.MCP_PREREGISTRATION],
73+
details: {
74+
expectedClientId: PRE_REGISTERED_CLIENT_ID,
75+
actualClientId: clientId
76+
}
77+
});
78+
return {
79+
error: 'invalid_client',
80+
errorDescription: 'Invalid pre-registered credentials',
81+
statusCode: 401
82+
};
83+
}
84+
85+
// Success - client used correct pre-registered credentials
86+
this.checks.push({
87+
id: 'pre-registration-auth',
88+
name: 'Pre-registration authentication',
89+
description:
90+
'Client correctly used pre-registered credentials when server does not support DCR',
91+
status: 'SUCCESS',
92+
timestamp,
93+
specReferences: [SpecReferences.MCP_PREREGISTRATION],
94+
details: { clientId }
95+
});
96+
97+
return {
98+
token: `test-token-prereg-${Date.now()}`,
99+
scopes: []
100+
};
101+
}
102+
});
103+
104+
await this.authServer.start(authApp);
105+
106+
const app = createServer(
107+
this.checks,
108+
this.server.getUrl,
109+
this.authServer.getUrl,
110+
{
111+
prmPath: '/.well-known/oauth-protected-resource/mcp',
112+
requiredScopes: [],
113+
tokenVerifier
114+
}
115+
);
116+
117+
await this.server.start(app);
118+
119+
return {
120+
serverUrl: `${this.server.getUrl()}/mcp`,
121+
context: {
122+
client_id: PRE_REGISTERED_CLIENT_ID,
123+
client_secret: PRE_REGISTERED_CLIENT_SECRET
124+
}
125+
};
126+
}
127+
128+
async stop() {
129+
await this.authServer.stop();
130+
await this.server.stop();
131+
}
132+
133+
getChecks(): ConformanceCheck[] {
134+
// Ensure we have the pre-registration check
135+
const hasPreRegCheck = this.checks.some(
136+
(c) => c.id === 'pre-registration-auth'
137+
);
138+
if (!hasPreRegCheck) {
139+
this.checks.push({
140+
id: 'pre-registration-auth',
141+
name: 'Pre-registration authentication',
142+
description: 'Client did not make a token request',
143+
status: 'FAILURE',
144+
timestamp: new Date().toISOString(),
145+
specReferences: [SpecReferences.MCP_PREREGISTRATION]
146+
});
147+
}
148+
149+
return this.checks;
150+
}
151+
}

src/scenarios/client/auth/spec-references.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,9 @@ export const SpecReferences: { [key: string]: SpecReference } = {
7272
SEP_1046_CLIENT_CREDENTIALS: {
7373
id: 'SEP-1046-Client-Credentials',
7474
url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx'
75+
},
76+
MCP_PREREGISTRATION: {
77+
id: 'MCP-Preregistration',
78+
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration'
7579
}
7680
};

src/schemas/context.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [
1717
name: z.literal('auth/client-credentials-basic'),
1818
client_id: z.string(),
1919
client_secret: z.string()
20+
}),
21+
z.object({
22+
name: z.literal('auth/pre-registration'),
23+
client_id: z.string(),
24+
client_secret: z.string()
2025
})
2126
]);
2227

0 commit comments

Comments
 (0)