Skip to content

Commit 02a03d5

Browse files
committed
chore: add more tests
1 parent 674cc9b commit 02a03d5

3 files changed

Lines changed: 238 additions & 9 deletions

File tree

examples/clients/typescript/everything-client.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,14 @@ async function runStatelessClient(serverUrl: string): Promise<void> {
115115
JSON.stringify(discoverResult.result)
116116
);
117117

118-
// Call tools/list with required inline _meta tags
118+
// Call tools/list with required inline _meta tags and header
119119
logger.debug('Calling tools/list with inline _meta...');
120120
const toolsResponse = await fetch(serverUrl, {
121121
method: 'POST',
122-
headers: { 'Content-Type': 'application/json' },
122+
headers: {
123+
'Content-Type': 'application/json',
124+
'MCP-Protocol-Version': 'DRAFT-2026-v1'
125+
},
123126
body: JSON.stringify({
124127
jsonrpc: '2.0',
125128
id: 2,
@@ -146,6 +149,78 @@ async function runStatelessClient(serverUrl: string): Promise<void> {
146149
JSON.stringify(toolsResult.result)
147150
);
148151

152+
// Test consistent version (send another request)
153+
logger.debug('Calling tools/list again to test consistent version...');
154+
const toolsResponse2 = await fetch(serverUrl, {
155+
method: 'POST',
156+
headers: {
157+
'Content-Type': 'application/json',
158+
'MCP-Protocol-Version': 'DRAFT-2026-v1'
159+
},
160+
body: JSON.stringify({
161+
jsonrpc: '2.0',
162+
id: 3,
163+
method: 'tools/list',
164+
params: {
165+
_meta: {
166+
'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1',
167+
'io.modelcontextprotocol/clientInfo': {
168+
name: 'conformance-test-client',
169+
version: '1.0.0'
170+
},
171+
'io.modelcontextprotocol/clientCapabilities': { roots: {} }
172+
}
173+
}
174+
})
175+
});
176+
await toolsResponse2.json();
177+
178+
// Test cancellation (HTTP: close stream)
179+
logger.debug('Calling long_running_task and cancelling by closing stream...');
180+
const controller = new AbortController();
181+
const signal = controller.signal;
182+
183+
const cancelPromise = fetch(serverUrl, {
184+
method: 'POST',
185+
headers: {
186+
'Content-Type': 'application/json',
187+
'MCP-Protocol-Version': 'DRAFT-2026-v1'
188+
},
189+
body: JSON.stringify({
190+
jsonrpc: '2.0',
191+
id: 4,
192+
method: 'tools/call',
193+
params: {
194+
name: 'long_running_task',
195+
arguments: {},
196+
_meta: {
197+
'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1',
198+
'io.modelcontextprotocol/clientInfo': {
199+
name: 'conformance-test-client',
200+
version: '1.0.0'
201+
},
202+
'io.modelcontextprotocol/clientCapabilities': { roots: {} }
203+
}
204+
}
205+
}),
206+
signal
207+
});
208+
209+
// Abort after a short delay to trigger cancellation
210+
setTimeout(() => {
211+
controller.abort();
212+
logger.debug('Aborted long running task stream');
213+
}, 100);
214+
215+
try {
216+
await cancelPromise;
217+
} catch (e) {
218+
logger.debug(
219+
'Long running task fetch threw expected error due to abort',
220+
e
221+
);
222+
}
223+
149224
logger.debug('Stateless client flow completed successfully');
150225
}
151226

src/scenarios/client/stateless.test.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,93 @@ async function badClient(serverUrl: string) {
2121
return response.json();
2222
}
2323

24+
// A client that flips versions between requests
25+
async function flippingVersionClient(serverUrl: string) {
26+
const metaA = {
27+
'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1',
28+
'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0' },
29+
'io.modelcontextprotocol/clientCapabilities': {}
30+
};
31+
const metaB = {
32+
...metaA,
33+
'io.modelcontextprotocol/protocolVersion': '2025-11-25'
34+
};
35+
36+
// Send first request
37+
await fetch(serverUrl, {
38+
method: 'POST',
39+
headers: {
40+
'Content-Type': 'application/json',
41+
'MCP-Protocol-Version': 'DRAFT-2026-v1'
42+
},
43+
body: JSON.stringify({
44+
jsonrpc: '2.0',
45+
id: 1,
46+
method: 'tools/list',
47+
params: { _meta: metaA }
48+
})
49+
});
50+
51+
// Send second request with different version
52+
const response = await fetch(serverUrl, {
53+
method: 'POST',
54+
headers: {
55+
'Content-Type': 'application/json',
56+
'MCP-Protocol-Version': '2025-11-25'
57+
},
58+
body: JSON.stringify({
59+
jsonrpc: '2.0',
60+
id: 2,
61+
method: 'tools/list',
62+
params: { _meta: metaB }
63+
})
64+
});
65+
return response.json();
66+
}
67+
68+
// A client that misses the HTTP header
69+
async function missingHeaderClient(serverUrl: string) {
70+
const response = await fetch(serverUrl, {
71+
method: 'POST',
72+
headers: { 'Content-Type': 'application/json' }, // Missing MCP-Protocol-Version header
73+
body: JSON.stringify({
74+
jsonrpc: '2.0',
75+
id: 1,
76+
method: 'tools/list',
77+
params: {
78+
_meta: {
79+
'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1',
80+
'io.modelcontextprotocol/clientInfo': {
81+
name: 'test',
82+
version: '1.0'
83+
},
84+
'io.modelcontextprotocol/clientCapabilities': {}
85+
}
86+
}
87+
})
88+
});
89+
return response.json();
90+
}
91+
2492
describe('Stateless Client Scenario Negative Tests', () => {
2593
test('client fails when omitting _meta', async () => {
2694
const runner = new InlineClientRunner(badClient);
27-
28-
// runClientAgainstScenario searches for the scenario by name in the registry
2995
await runClientAgainstScenario(runner, 'stateless-client', {
3096
expectedFailureSlugs: ['client-populates-meta']
3197
});
3298
});
99+
100+
test('client fails when flipping versions', async () => {
101+
const runner = new InlineClientRunner(flippingVersionClient);
102+
await runClientAgainstScenario(runner, 'stateless-client', {
103+
expectedFailureSlugs: ['client-consistent-version']
104+
});
105+
});
106+
107+
test('client fails when missing version header', async () => {
108+
const runner = new InlineClientRunner(missingHeaderClient);
109+
await runClientAgainstScenario(runner, 'stateless-client', {
110+
expectedFailureSlugs: ['client-sends-version-header']
111+
});
112+
});
33113
});

src/scenarios/client/stateless.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class StatelessClientScenario implements Scenario {
1414

1515
private server: http.Server | null = null;
1616
private checks: ConformanceCheck[] = [];
17+
private negotiatedVersion: string | null = null;
1718

1819
async start(): Promise<ScenarioUrls> {
1920
return new Promise((resolve, reject) => {
@@ -57,7 +58,43 @@ export class StatelessClientScenario implements Scenario {
5758
req.on('end', () => {
5859
const request = JSON.parse(body);
5960

60-
// TEST 1: Verify client can call server/discover
61+
// Extract version and headers
62+
const meta = request.params?._meta;
63+
const currentVersion = meta?.['io.modelcontextprotocol/protocolVersion'];
64+
const headerVersion = req.headers['mcp-protocol-version'];
65+
66+
// [HTTP] Sends MCP-Protocol-Version header on every request, equal to _meta.protocolVersion
67+
if (currentVersion) {
68+
this.checks.push({
69+
id: 'client-sends-version-header',
70+
name: 'ClientSendsVersionHeader',
71+
description:
72+
'Client sends MCP-Protocol-Version header equal to _meta.protocolVersion',
73+
status: headerVersion === currentVersion ? 'SUCCESS' : 'FAILURE',
74+
timestamp: new Date().toISOString(),
75+
specReferences: [{ id: 'SEP-2575', url: '' }]
76+
});
77+
}
78+
79+
// Sends a consistent protocolVersion once chosen
80+
if (currentVersion) {
81+
if (!this.negotiatedVersion) {
82+
this.negotiatedVersion = currentVersion;
83+
} else {
84+
this.checks.push({
85+
id: 'client-consistent-version',
86+
name: 'ClientConsistentVersion',
87+
description:
88+
'Client sends a consistent protocolVersion once chosen',
89+
status:
90+
currentVersion === this.negotiatedVersion ? 'SUCCESS' : 'FAILURE',
91+
timestamp: new Date().toISOString(),
92+
specReferences: [{ id: 'SEP-2575', url: '' }]
93+
});
94+
}
95+
}
96+
97+
// Verify client can call server/discover
6198
if (request.method === 'server/discover') {
6299
this.checks.push({
63100
id: 'client-calls-discover',
@@ -68,14 +105,14 @@ export class StatelessClientScenario implements Scenario {
68105
specReferences: [{ id: 'SEP-2575', url: '' }]
69106
});
70107

71-
// Respond with valid discovery payload to keep client happy
108+
// Respond with valid discovery payload
72109
res.writeHead(200, { 'Content-Type': 'application/json' });
73110
res.end(
74111
JSON.stringify({
75112
jsonrpc: '2.0',
76113
id: request.id,
77114
result: {
78-
supportedVersions: ['2026-06-18'],
115+
supportedVersions: [DRAFT_PROTOCOL_VERSION],
79116
capabilities: {},
80117
serverInfo: { name: 'test', version: '1.0' }
81118
}
@@ -84,8 +121,24 @@ export class StatelessClientScenario implements Scenario {
84121
return;
85122
}
86123

87-
// TEST 2: Verify inline _meta on every request
88-
const meta = request.params?._meta;
124+
// [STDIO] Cancels by sending notifications/cancelled with the request id
125+
if (request.method === 'notifications/cancelled') {
126+
this.checks.push({
127+
id: 'client-cancels-by-notification',
128+
name: 'ClientCancelsByNotification',
129+
description:
130+
'Client cancels by sending notifications/cancelled with the request id',
131+
status: 'SUCCESS',
132+
timestamp: new Date().toISOString(),
133+
specReferences: [{ id: 'SEP-2575', url: '' }]
134+
});
135+
136+
res.writeHead(200, { 'Content-Type': 'application/json' });
137+
res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: {} }));
138+
return;
139+
}
140+
141+
// Verify inline _meta on every request
89142
const hasProtocolVersion =
90143
meta?.['io.modelcontextprotocol/protocolVersion'];
91144
const hasClientInfo = meta?.['io.modelcontextprotocol/clientInfo'];
@@ -106,6 +159,27 @@ export class StatelessClientScenario implements Scenario {
106159
details: { meta }
107160
});
108161

162+
// Handle long running task for cancellation testing
163+
if (
164+
request.method === 'tools/call' &&
165+
request.params?.name === 'long_running_task'
166+
) {
167+
// Do not respond immediately, wait for client to abort (req close) or send cancel notification
168+
req.on('close', () => {
169+
if (!res.writableEnded) {
170+
this.checks.push({
171+
id: 'client-cancels-by-closing-stream',
172+
name: 'ClientCancelsByClosingStream',
173+
description: 'Client cancels by closing the stream (request)',
174+
status: 'SUCCESS',
175+
timestamp: new Date().toISOString(),
176+
specReferences: [{ id: 'SEP-2575', url: '' }]
177+
});
178+
}
179+
});
180+
return; // Keep request open
181+
}
182+
109183
// Return generic response to unblock client
110184
res.writeHead(200, { 'Content-Type': 'application/json' });
111185
res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: {} }));

0 commit comments

Comments
 (0)