|
10 | 10 | */ |
11 | 11 |
|
12 | 12 | 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'; |
15 | 22 | import { expect, vi } from 'vitest'; |
16 | 23 | import { z } from 'zod/v4'; |
17 | 24 |
|
| 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 | + |
18 | 32 | import { hostPerSession, wire } from '../helpers/index.js'; |
19 | 33 | import { verifies } from '../helpers/verifies.js'; |
20 | 34 | import type { TestArgs } from '../types.js'; |
@@ -165,3 +179,135 @@ verifies('hosting:context:web-request-headers', async (_args: TestArgs) => { |
165 | 179 | await mcpHost.close(); |
166 | 180 | } |
167 | 181 | }); |
| 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