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
This commit is contained in:
Paul Carleton 2026-01-23 11:43:56 +00:00 • committed by GitHub
commit 245e44e882
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 304 additions and 4 deletions

View file

@ -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>');

View file

@ -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,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
// ============================================================================

View file

@ -45,6 +45,7 @@ export const handle401 = async (
}
}
};
/**
* Creates a fetch wrapper that handles OAuth authentication with retry logic.
*
@ -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 = (
@ -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,