Skip to content

Commit 2fbd605

Browse files
test(e2e): cover the per-request envelope on the handler context
1 parent 12be4ff commit 2fbd605

2 files changed

Lines changed: 162 additions & 2 deletions

File tree

test/e2e/requirements.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,20 @@ export const REQUIREMENTS: Record<string, Requirement> = {
114114
transports: STATEFUL_TRANSPORTS,
115115
note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.'
116116
},
117+
'protocol:envelope:ctx-version-readable': {
118+
source: 'sdk',
119+
behavior:
120+
'A request handler can read the protocol version governing the current request from its context (ctx.mcpReq.protocolVersion); on a 2025 connection it equals the initialize-negotiated version.',
121+
transports: STATEFUL_TRANSPORTS,
122+
note: "Groundwork for SEP-2575's per-request envelope. Today the version is sourced from the initialize handshake, so a stateless host (fresh server per request, no retained handshake) would read the pre-initialize default rather than the negotiated version."
123+
},
124+
'protocol:envelope:ctx-capabilities-readable': {
125+
source: 'sdk',
126+
behavior:
127+
"A server request handler can read the calling client's declared capabilities and implementation info from its context (ctx.client.capabilities / ctx.client.info); on a 2025 connection these equal what the client sent at initialize.",
128+
transports: STATEFUL_TRANSPORTS,
129+
note: 'Per-request counterpart of Server.getClientCapabilities()/getClientVersion(); under the 2026 revision (no handshake) the per-request form becomes the only way to read these. The structural supersession is recorded when 2026 connections become possible. Stateless hosting serves each request from a fresh server that never processed initialize, so the handshake-sourced facts are not observable there.'
130+
},
117131

118132
// Protocol primitives: cancellation, timeout, progress, errors, _meta
119133

test/e2e/scenarios/handler-context.test.ts

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,25 @@
1010
*/
1111

1212
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
13-
import type { CreateMessageRequest, ElicitRequest, ElicitRequestFormParams, LoggingLevel } from '@modelcontextprotocol/server';
14-
import { McpServer } from '@modelcontextprotocol/server';
13+
import type {
14+
ClientCapabilities,
15+
CreateMessageRequest,
16+
ElicitRequest,
17+
ElicitRequestFormParams,
18+
Implementation,
19+
LoggingLevel
20+
} from '@modelcontextprotocol/server';
21+
import { LATEST_PROTOCOL_VERSION, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server';
1522
import { expect, vi } from 'vitest';
1623
import { z } from 'zod/v4';
1724

25+
/** A supported protocol version other than the latest, used to prove the version reported is the negotiated one. */
26+
const OLDER_SUPPORTED_VERSION = (() => {
27+
const older = SUPPORTED_PROTOCOL_VERSIONS.find(v => v !== LATEST_PROTOCOL_VERSION);
28+
if (older === undefined) throw new Error('expected SUPPORTED_PROTOCOL_VERSIONS to include a version other than the latest');
29+
return older;
30+
})();
31+
1832
import { hostPerSession, wire } from '../helpers/index.js';
1933
import { verifies } from '../helpers/verifies.js';
2034
import type { TestArgs } from '../types.js';
@@ -165,3 +179,135 @@ verifies('hosting:context:web-request-headers', async (_args: TestArgs) => {
165179
await mcpHost.close();
166180
}
167181
});
182+
183+
verifies(
184+
'protocol:envelope:ctx-version-readable',
185+
async ({ transport }: TestArgs) => {
186+
let seenVersion: string | undefined;
187+
const makeServer = () => {
188+
const s = new McpServer({ name: 's', version: '0' });
189+
s.registerTool('read-version', { inputSchema: z.object({}) }, (_args, ctx) => {
190+
seenVersion = ctx.mcpReq.protocolVersion;
191+
return { content: [{ type: 'text', text: ctx.mcpReq.protocolVersion }] };
192+
});
193+
return s;
194+
};
195+
const client = new Client({ name: 'c', version: '0' });
196+
197+
await using _ = await wire(transport, makeServer, client);
198+
const result = await client.callTool({ name: 'read-version', arguments: {} });
199+
200+
// On a 2025 connection the governing version is the one negotiated at initialize.
201+
expect(seenVersion).toBe(client.getNegotiatedProtocolVersion());
202+
expect(result.content).toEqual([{ type: 'text', text: client.getNegotiatedProtocolVersion() }]);
203+
},
204+
{ title: 'server handler reads negotiated version (default)' }
205+
);
206+
207+
verifies(
208+
'protocol:envelope:ctx-version-readable',
209+
async ({ transport }: TestArgs) => {
210+
let seenVersion: string | undefined;
211+
const makeServer = () => {
212+
const s = new McpServer({ name: 's', version: '0' });
213+
s.registerTool('read-version', { inputSchema: z.object({}) }, (_args, ctx) => {
214+
seenVersion = ctx.mcpReq.protocolVersion;
215+
return { content: [{ type: 'text', text: ctx.mcpReq.protocolVersion }] };
216+
});
217+
return s;
218+
};
219+
// Pin the client to an older supported version so the governing version differs from the latest,
220+
// proving the handler reads the actually-negotiated version rather than a constant.
221+
const client = new Client({ name: 'c', version: '0' }, { supportedProtocolVersions: [OLDER_SUPPORTED_VERSION] });
222+
223+
await using _ = await wire(transport, makeServer, client);
224+
await client.callTool({ name: 'read-version', arguments: {} });
225+
226+
expect(seenVersion).toBe(OLDER_SUPPORTED_VERSION);
227+
expect(client.getNegotiatedProtocolVersion()).toBe(OLDER_SUPPORTED_VERSION);
228+
},
229+
{ title: 'server handler reads negotiated version (pinned older)' }
230+
);
231+
232+
verifies(
233+
'protocol:envelope:ctx-version-readable',
234+
async ({ transport }: TestArgs) => {
235+
// BaseContext is shared by both roles: a client-side handler can read the governing version too.
236+
let seenByClientHandler: string | undefined;
237+
const makeServer = () => {
238+
const s = new McpServer({ name: 's', version: '0' });
239+
s.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => {
240+
const r = await ctx.mcpReq.requestSampling({
241+
messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }],
242+
maxTokens: 10
243+
});
244+
const text = !Array.isArray(r.content) && r.content.type === 'text' ? r.content.text : '<unexpected>';
245+
return { content: [{ type: 'text', text }] };
246+
});
247+
return s;
248+
};
249+
const client = new Client({ name: 'c', version: '0' }, { capabilities: { sampling: {} } });
250+
client.setRequestHandler('sampling/createMessage', async (_req, ctx) => {
251+
seenByClientHandler = ctx.mcpReq.protocolVersion;
252+
return { model: 'stub', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
253+
});
254+
255+
await using _ = await wire(transport, makeServer, client);
256+
await client.callTool({ name: 'ask', arguments: {} });
257+
258+
expect(seenByClientHandler).toBe(client.getNegotiatedProtocolVersion());
259+
},
260+
{ title: 'client handler reads governing version' }
261+
);
262+
263+
verifies(
264+
'protocol:envelope:ctx-capabilities-readable',
265+
async ({ transport }: TestArgs) => {
266+
let seen: { capabilities: ClientCapabilities; info: Implementation | undefined } | undefined;
267+
const makeServer = () => {
268+
const s = new McpServer({ name: 's', version: '0' });
269+
s.registerTool('read-client', { inputSchema: z.object({}) }, (_args, ctx) => {
270+
seen = { capabilities: ctx.client.capabilities, info: ctx.client.info };
271+
return { content: [{ type: 'text', text: 'ok' }] };
272+
});
273+
return s;
274+
};
275+
const declared: ClientCapabilities = { sampling: {}, roots: { listChanged: true } };
276+
const clientInfo: Implementation = { name: 'declaring-client', version: '4.5.6' };
277+
const client = new Client(clientInfo, { capabilities: declared });
278+
279+
await using _ = await wire(transport, makeServer, client);
280+
await client.callTool({ name: 'read-client', arguments: {} });
281+
282+
// The handler sees exactly what the client declared at initialize.
283+
expect(seen?.capabilities.sampling).toEqual({});
284+
expect(seen?.capabilities.roots).toEqual({ listChanged: true });
285+
expect(seen?.info).toEqual(clientInfo);
286+
},
287+
{ title: 'declared capabilities and info' }
288+
);
289+
290+
verifies(
291+
'protocol:envelope:ctx-capabilities-readable',
292+
async ({ transport }: TestArgs) => {
293+
let seen: { capabilities: ClientCapabilities; info: Implementation | undefined } | undefined;
294+
const makeServer = () => {
295+
const s = new McpServer({ name: 's', version: '0' });
296+
s.registerTool('read-client', { inputSchema: z.object({}) }, (_args, ctx) => {
297+
seen = { capabilities: ctx.client.capabilities, info: ctx.client.info };
298+
return { content: [{ type: 'text', text: 'ok' }] };
299+
});
300+
return s;
301+
};
302+
// A client that declares no optional capabilities yields a `{}`-shaped object, not undefined.
303+
const clientInfo: Implementation = { name: 'bare-client', version: '0.0.1' };
304+
const client = new Client(clientInfo);
305+
306+
await using _ = await wire(transport, makeServer, client);
307+
await client.callTool({ name: 'read-client', arguments: {} });
308+
309+
expect(seen?.capabilities).toEqual({});
310+
expect(seen?.info).toEqual(clientInfo);
311+
},
312+
{ title: 'no optional capabilities yields {} shape' }
313+
);

0 commit comments

Comments
 (0)