Skip to content

Commit e4df79d

Browse files
committed
wip...
1 parent 6464588 commit e4df79d

File tree

7 files changed

+856
-31
lines changed

7 files changed

+856
-31
lines changed

examples/clients/typescript/auth-test.ts

Lines changed: 114 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
44
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5+
import {
6+
ClientCredentialsProvider,
7+
PrivateKeyJwtProvider
8+
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
9+
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
510
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry';
611
import { runAsCli } from './helpers/cliRunner';
712
import { logger } from './helpers/logger';
@@ -15,36 +20,130 @@ const CIMD_CLIENT_METADATA_URL =
1520
'https://conformance-test.local/client-metadata.json';
1621

1722
/**
18-
* Well-behaved auth client that follows all OAuth protocols correctly.
23+
* Context passed from the conformance test framework via MCP_CONFORMANCE_CONTEXT env var.
24+
*
25+
* WARNING: This schema is unstable and subject to change.
26+
* Currently only used for client credentials scenarios.
27+
* See: https://github.com/modelcontextprotocol/conformance/issues/51
1928
*/
20-
export async function runClient(serverUrl: string): Promise<void> {
29+
interface ConformanceContext {
30+
scenario: string;
31+
client_id?: string;
32+
// For JWT auth (private_key_jwt)
33+
private_key_pem?: string;
34+
signing_algorithm?: string;
35+
// For basic auth (client_secret_basic)
36+
client_secret?: string;
37+
}
38+
39+
function getContext(
40+
passedContext?: Record<string, unknown>
41+
): ConformanceContext {
42+
if (passedContext) {
43+
return passedContext as ConformanceContext;
44+
}
45+
const contextJson = process.env.MCP_CONFORMANCE_CONTEXT;
46+
if (!contextJson) {
47+
throw new Error('MCP_CONFORMANCE_CONTEXT environment variable is required');
48+
}
49+
return JSON.parse(contextJson);
50+
}
51+
52+
/**
53+
* Create an OAuth provider based on the scenario type.
54+
*/
55+
function createProviderForScenario(
56+
context: ConformanceContext
57+
): OAuthClientProvider | undefined {
58+
const { scenario } = context;
59+
60+
// Client credentials scenarios use the dedicated provider classes
61+
if (scenario === 'auth/client-credentials-jwt') {
62+
if (
63+
!context.client_id ||
64+
!context.private_key_pem ||
65+
!context.signing_algorithm
66+
) {
67+
throw new Error(
68+
'auth/client-credentials-jwt requires client_id, private_key_pem, and signing_algorithm in context'
69+
);
70+
}
71+
return new PrivateKeyJwtProvider({
72+
clientId: context.client_id,
73+
privateKey: context.private_key_pem,
74+
algorithm: context.signing_algorithm,
75+
clientName: 'conformance-client-credentials'
76+
});
77+
}
78+
79+
if (scenario === 'auth/client-credentials-basic') {
80+
if (!context.client_id || !context.client_secret) {
81+
throw new Error(
82+
'auth/client-credentials-basic requires client_id and client_secret in context'
83+
);
84+
}
85+
return new ClientCredentialsProvider({
86+
clientId: context.client_id,
87+
clientSecret: context.client_secret,
88+
clientName: 'conformance-client-credentials'
89+
});
90+
}
91+
92+
// For authorization code flow scenarios, return undefined to use withOAuthRetry
93+
return undefined;
94+
}
95+
96+
/**
97+
* Auth client that handles both authorization code flow and client credentials flow
98+
* based on the scenario name in the conformance context.
99+
*/
100+
export async function runClient(
101+
serverUrl: string,
102+
passedContext?: Record<string, unknown>
103+
): Promise<void> {
104+
const context = getContext(passedContext);
105+
logger.debug('Parsed context:', JSON.stringify(context, null, 2));
106+
21107
const client = new Client(
22108
{ name: 'test-auth-client', version: '1.0.0' },
23109
{ capabilities: {} }
24110
);
25111

26-
const oauthFetch = withOAuthRetry(
27-
'test-auth-client',
28-
new URL(serverUrl),
29-
handle401,
30-
CIMD_CLIENT_METADATA_URL
31-
)(fetch);
112+
// Check if this is a client credentials scenario
113+
const clientCredentialsProvider = createProviderForScenario(context);
114+
115+
let transport: StreamableHTTPClientTransport;
116+
117+
if (clientCredentialsProvider) {
118+
// Client credentials flow - use the provider directly
119+
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
120+
authProvider: clientCredentialsProvider
121+
});
122+
} else {
123+
// Authorization code flow - use withOAuthRetry middleware
124+
const oauthFetch = withOAuthRetry(
125+
'test-auth-client',
126+
new URL(serverUrl),
127+
handle401,
128+
CIMD_CLIENT_METADATA_URL
129+
)(fetch);
32130

33-
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
34-
fetch: oauthFetch
35-
});
131+
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
132+
fetch: oauthFetch
133+
});
134+
}
36135

37136
await client.connect(transport);
38-
logger.debug('Successfully connected to MCP server');
137+
logger.debug('Successfully connected to MCP server');
39138

40139
await client.listTools();
41-
logger.debug('Successfully listed tools');
140+
logger.debug('Successfully listed tools');
42141

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

46145
await transport.close();
47-
logger.debug('Connection closed successfully');
146+
logger.debug('Connection closed successfully');
48147
}
49148

50149
runAsCli(runClient, import.meta.url, 'auth-test <server-url>');

examples/servers/typescript/everything-server.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,141 @@ function createMcpServer() {
389389
}
390390
);
391391

392+
// SEP-1699: Event replay test tool - closes stream mid-call, sends more events, tests replay
393+
mcpServer.registerTool(
394+
'test_event_replay',
395+
{
396+
description:
397+
'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.',
398+
inputSchema: {}
399+
},
400+
async (_args, { sessionId, requestId, sendNotification }) => {
401+
const sleep = (ms: number) =>
402+
new Promise((resolve) => setTimeout(resolve, ms));
403+
404+
console.log(`[${sessionId}] Starting test_event_replay tool...`);
405+
406+
// Send notification1 before closing
407+
await sendNotification({
408+
method: 'notifications/message',
409+
params: {
410+
level: 'info',
411+
data: 'notification1'
412+
}
413+
});
414+
console.log(`[${sessionId}] Sent notification1`);
415+
416+
// Get the transport for this session
417+
const transport = sessionId ? transports[sessionId] : undefined;
418+
if (transport && requestId) {
419+
// Close the SSE stream to trigger client reconnection
420+
console.log(`[${sessionId}] Closing SSE stream...`);
421+
transport.closeSSEStream(requestId);
422+
}
423+
424+
// Wait a bit for stream to close
425+
await sleep(100);
426+
427+
// Send notification2 and notification3 (should be stored in event store)
428+
await sendNotification({
429+
method: 'notifications/message',
430+
params: {
431+
level: 'info',
432+
data: 'notification2'
433+
}
434+
});
435+
console.log(`[${sessionId}] Sent notification2 (stored for replay)`);
436+
437+
await sendNotification({
438+
method: 'notifications/message',
439+
params: {
440+
level: 'info',
441+
data: 'notification3'
442+
}
443+
});
444+
console.log(`[${sessionId}] Sent notification3 (stored for replay)`);
445+
446+
// Wait for client to reconnect
447+
await sleep(200);
448+
449+
console.log(`[${sessionId}] test_event_replay tool complete`);
450+
451+
return {
452+
content: [
453+
{
454+
type: 'text',
455+
text: 'Event replay test completed. You should have received notification1, notification2, and notification3.'
456+
}
457+
]
458+
};
459+
}
460+
);
461+
462+
// SEP-1699: Multiple reconnections test tool - closes stream multiple times
463+
mcpServer.registerTool(
464+
'test_multiple_reconnections',
465+
{
466+
description:
467+
'Tests multiple SSE stream closures during single tool call (SEP-1699). Sends checkpoint notifications and closes stream at each checkpoint.',
468+
inputSchema: {
469+
checkpoints: z
470+
.number()
471+
.min(1)
472+
.max(10)
473+
.default(3)
474+
.describe('Number of checkpoints (stream closures)')
475+
}
476+
},
477+
async (
478+
args: { checkpoints?: number },
479+
{ sessionId, requestId, sendNotification }
480+
) => {
481+
const sleep = (ms: number) =>
482+
new Promise((resolve) => setTimeout(resolve, ms));
483+
484+
const numCheckpoints = args.checkpoints ?? 3;
485+
console.log(
486+
`[${sessionId}] Starting test_multiple_reconnections with ${numCheckpoints} checkpoints...`
487+
);
488+
489+
const transport = sessionId ? transports[sessionId] : undefined;
490+
491+
for (let i = 0; i < numCheckpoints; i++) {
492+
// Send checkpoint notification
493+
await sendNotification({
494+
method: 'notifications/message',
495+
params: {
496+
level: 'info',
497+
data: `checkpoint_${i}`
498+
}
499+
});
500+
console.log(`[${sessionId}] Sent checkpoint_${i}`);
501+
502+
// Close the SSE stream
503+
if (transport && requestId) {
504+
console.log(
505+
`[${sessionId}] Closing SSE stream at checkpoint ${i}...`
506+
);
507+
transport.closeSSEStream(requestId);
508+
}
509+
510+
// Wait for client to reconnect (should respect retry field)
511+
await sleep(200);
512+
}
513+
514+
console.log(`[${sessionId}] test_multiple_reconnections tool complete`);
515+
516+
return {
517+
content: [
518+
{
519+
type: 'text',
520+
text: `Completed ${numCheckpoints} checkpoints with stream closures. You should have received all checkpoint notifications.`
521+
}
522+
]
523+
};
524+
}
525+
);
526+
392527
// Sampling tool - requests LLM completion from client
393528
mcpServer.registerTool(
394529
'test_sampling',

src/runner/client.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,21 @@ export async function runConformanceTest(
9797
console.error(`Starting scenario: ${scenarioName}`);
9898
const urls = await scenario.start();
9999

100+
// Always include scenario name in context
101+
const context = {
102+
...urls.context,
103+
scenario: scenarioName
104+
};
105+
100106
console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`);
101-
if (urls.context) {
102-
console.error(`With context: ${JSON.stringify(urls.context)}`);
103-
}
107+
console.error(`With context: ${JSON.stringify(context)}`);
104108

105109
try {
106110
const clientOutput = await executeClient(
107111
clientCommand,
108112
urls.serverUrl,
109113
timeout,
110-
urls.context
114+
context
111115
);
112116

113117
// Print stdout/stderr if client exited with nonzero code

src/scenarios/client/auth/client-credentials.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,8 @@ export class ClientCredentialsJwtScenario implements Scenario {
5151
tokenEndpointAuthSigningAlgValuesSupported: ['ES256'],
5252
onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => {
5353
// Per RFC 7523bis, the audience MUST be the issuer identifier
54-
const issuerUrl = authBaseUrl.endsWith('/')
55-
? authBaseUrl
56-
: `${authBaseUrl}/`;
54+
// Accept both with and without trailing slash since RFC 8414 doesn't specify
55+
const issuerUrl = authBaseUrl;
5756
if (grantType !== 'client_credentials') {
5857
this.checks.push({
5958
id: 'client-credentials-grant-type',

0 commit comments

Comments
 (0)