Skip to content
Draft
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
129 changes: 114 additions & 15 deletions examples/clients/typescript/auth-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

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

Check failure on line 8 in examples/clients/typescript/auth-test.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find module '@modelcontextprotocol/sdk/client/auth-extensions.js' or its corresponding type declarations.
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';
Expand All @@ -15,36 +20,130 @@
'https://conformance-test.local/client-metadata.json';

/**
* Well-behaved auth client that follows all OAuth protocols correctly.
* Context passed from the conformance test framework via MCP_CONFORMANCE_CONTEXT env var.
*
* WARNING: This schema is unstable and subject to change.
* Currently only used for client credentials scenarios.
* See: https://github.com/modelcontextprotocol/conformance/issues/51
*/
export async function runClient(serverUrl: string): Promise<void> {
interface ConformanceContext {
scenario: string;
client_id?: string;
// For JWT auth (private_key_jwt)
private_key_pem?: string;
signing_algorithm?: string;
// For basic auth (client_secret_basic)
client_secret?: string;
}

function getContext(
passedContext?: Record<string, unknown>
): ConformanceContext {
if (passedContext) {
return passedContext as ConformanceContext;

Check failure on line 43 in examples/clients/typescript/auth-test.ts

View workflow job for this annotation

GitHub Actions / test

Conversion of type 'Record<string, unknown>' to type 'ConformanceContext' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
}
const contextJson = process.env.MCP_CONFORMANCE_CONTEXT;
if (!contextJson) {
throw new Error('MCP_CONFORMANCE_CONTEXT environment variable is required');
}
return JSON.parse(contextJson);
}

/**
* Create an OAuth provider based on the scenario type.
*/
function createProviderForScenario(
context: ConformanceContext
): OAuthClientProvider | undefined {
const { scenario } = context;

// Client credentials scenarios use the dedicated provider classes
if (scenario === 'auth/client-credentials-jwt') {
if (
!context.client_id ||
!context.private_key_pem ||
!context.signing_algorithm
) {
throw new Error(
'auth/client-credentials-jwt requires client_id, private_key_pem, and signing_algorithm in context'
);
}
return new PrivateKeyJwtProvider({
clientId: context.client_id,
privateKey: context.private_key_pem,
algorithm: context.signing_algorithm,
clientName: 'conformance-client-credentials'
});
}

if (scenario === 'auth/client-credentials-basic') {
if (!context.client_id || !context.client_secret) {
throw new Error(
'auth/client-credentials-basic requires client_id and client_secret in context'
);
}
return new ClientCredentialsProvider({
clientId: context.client_id,
clientSecret: context.client_secret,
clientName: 'conformance-client-credentials'
});
}

// For authorization code flow scenarios, return undefined to use withOAuthRetry
return undefined;
}

/**
* Auth client that handles both authorization code flow and client credentials flow
* based on the scenario name in the conformance context.
*/
export async function runClient(
serverUrl: string,
passedContext?: Record<string, unknown>
): Promise<void> {
const context = getContext(passedContext);
logger.debug('Parsed context:', JSON.stringify(context, null, 2));

const client = new Client(
{ name: 'test-auth-client', version: '1.0.0' },
{ capabilities: {} }
);

const oauthFetch = withOAuthRetry(
'test-auth-client',
new URL(serverUrl),
handle401,
CIMD_CLIENT_METADATA_URL
)(fetch);
// Check if this is a client credentials scenario
const clientCredentialsProvider = createProviderForScenario(context);

let transport: StreamableHTTPClientTransport;

if (clientCredentialsProvider) {
// Client credentials flow - use the provider directly
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
authProvider: clientCredentialsProvider
});
} else {
// Authorization code flow - use withOAuthRetry middleware
const oauthFetch = withOAuthRetry(
'test-auth-client',
new URL(serverUrl),
handle401,
CIMD_CLIENT_METADATA_URL
)(fetch);

const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
}

await client.connect(transport);
logger.debug('Successfully connected to MCP server');
logger.debug('Successfully connected to MCP server');

await client.listTools();
logger.debug('Successfully listed tools');
logger.debug('Successfully listed tools');

await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('Successfully called tool');
logger.debug('Successfully called tool');

await transport.close();
logger.debug('Connection closed successfully');
logger.debug('Connection closed successfully');
}

runAsCli(runClient, import.meta.url, 'auth-test <server-url>');
135 changes: 135 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,141 @@ function createMcpServer() {
}
);

// SEP-1699: Event replay test tool - closes stream mid-call, sends more events, tests replay
mcpServer.registerTool(
'test_event_replay',
{
description:
'Tests SSE event replay after disconnection (SEP-1699). Sends notification1, closes stream, sends notification2 and notification3, then returns. Client should receive all notifications via event replay.',
inputSchema: {}
},
async (_args, { sessionId, requestId, sendNotification }) => {
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

console.log(`[${sessionId}] Starting test_event_replay tool...`);

// Send notification1 before closing
await sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: 'notification1'
}
});
console.log(`[${sessionId}] Sent notification1`);

// Get the transport for this session
const transport = sessionId ? transports[sessionId] : undefined;
if (transport && requestId) {
// Close the SSE stream to trigger client reconnection
console.log(`[${sessionId}] Closing SSE stream...`);
transport.closeSSEStream(requestId);
}

// Wait a bit for stream to close
await sleep(100);

// Send notification2 and notification3 (should be stored in event store)
await sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: 'notification2'
}
});
console.log(`[${sessionId}] Sent notification2 (stored for replay)`);

await sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: 'notification3'
}
});
console.log(`[${sessionId}] Sent notification3 (stored for replay)`);

// Wait for client to reconnect
await sleep(200);

console.log(`[${sessionId}] test_event_replay tool complete`);

return {
content: [
{
type: 'text',
text: 'Event replay test completed. You should have received notification1, notification2, and notification3.'
}
]
};
}
);

// SEP-1699: Multiple reconnections test tool - closes stream multiple times
mcpServer.registerTool(
'test_multiple_reconnections',
{
description:
'Tests multiple SSE stream closures during single tool call (SEP-1699). Sends checkpoint notifications and closes stream at each checkpoint.',
inputSchema: {
checkpoints: z
.number()
.min(1)
.max(10)
.default(3)
.describe('Number of checkpoints (stream closures)')
}
},
async (
args: { checkpoints?: number },
{ sessionId, requestId, sendNotification }
) => {
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

const numCheckpoints = args.checkpoints ?? 3;
console.log(
`[${sessionId}] Starting test_multiple_reconnections with ${numCheckpoints} checkpoints...`
);

const transport = sessionId ? transports[sessionId] : undefined;

for (let i = 0; i < numCheckpoints; i++) {
// Send checkpoint notification
await sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: `checkpoint_${i}`
}
});
console.log(`[${sessionId}] Sent checkpoint_${i}`);

// Close the SSE stream
if (transport && requestId) {
console.log(
`[${sessionId}] Closing SSE stream at checkpoint ${i}...`
);
transport.closeSSEStream(requestId);
}

// Wait for client to reconnect (should respect retry field)
await sleep(200);
}

console.log(`[${sessionId}] test_multiple_reconnections tool complete`);

return {
content: [
{
type: 'text',
text: `Completed ${numCheckpoints} checkpoints with stream closures. You should have received all checkpoint notifications.`
}
]
};
}
);

// Sampling tool - requests LLM completion from client
mcpServer.registerTool(
'test_sampling',
Expand Down
12 changes: 8 additions & 4 deletions src/runner/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,21 @@ export async function runConformanceTest(
console.error(`Starting scenario: ${scenarioName}`);
const urls = await scenario.start();

// Always include scenario name in context
const context = {
...urls.context,
scenario: scenarioName
};

console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`);
if (urls.context) {
console.error(`With context: ${JSON.stringify(urls.context)}`);
}
console.error(`With context: ${JSON.stringify(context)}`);

try {
const clientOutput = await executeClient(
clientCommand,
urls.serverUrl,
timeout,
urls.context
context
);

// Print stdout/stderr if client exited with nonzero code
Expand Down
5 changes: 2 additions & 3 deletions src/scenarios/client/auth/client-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,8 @@ export class ClientCredentialsJwtScenario implements Scenario {
tokenEndpointAuthSigningAlgValuesSupported: ['ES256'],
onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => {
// Per RFC 7523bis, the audience MUST be the issuer identifier
const issuerUrl = authBaseUrl.endsWith('/')
? authBaseUrl
: `${authBaseUrl}/`;
// Accept both with and without trailing slash since RFC 8414 doesn't specify
const issuerUrl = authBaseUrl;
if (grantType !== 'client_credentials') {
this.checks.push({
id: 'client-credentials-grant-type',
Expand Down
Loading
Loading