diff --git a/.changeset/sampling-context-capability-gate.md b/.changeset/sampling-context-capability-gate.md new file mode 100644 index 0000000000..4390e78c84 --- /dev/null +++ b/.changeset/sampling-context-capability-gate.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`Server.createMessage()` (and `ctx.mcpReq.requestSampling()`, which calls it) now refuses `includeContext: 'thisServer'` or `'allServers'` when the client did not declare the `sampling.context` capability, throwing `SdkError` with `SdkErrorCode.CapabilityNotSupported` before anything is sent, as it already does for `tools` without `sampling.tools`. The 2025-11-25 spec says servers SHOULD only use these values when the client declares `sampling.context`. A server that sends them to such a client must now omit `includeContext` or use `'none'`. Connections negotiated at a revision before 2025-11-25, which has no `sampling.context` capability, are unaffected. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 441857f646..e882d6f3c4 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1107,6 +1107,15 @@ export class Server extends Protocol { if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); } + // sampling.context is a 2025-11-25 capability: older revisions allow thisServer/allServers without it + const predatesContextCapability = this._negotiatedProtocolVersion !== undefined && this._negotiatedProtocolVersion < '2025-11-25'; + if ( + (params.includeContext === 'thisServer' || params.includeContext === 'allServers') && + !predatesContextCapability && + !this._clientCapabilities?.sampling?.context + ) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling context capability.'); + } // Message structure validation - always validate tool_use/tool_result pairs. // These may appear even without tools/toolChoice in the current request when diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 36da4fc523..0e466235a1 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1113,12 +1113,7 @@ export const REQUIREMENTS: Record = { behavior: 'The server does not use includeContext values thisServer or allServers unless the client declared the sampling.context capability.', transports: ['inMemory', 'stdio', 'streamableHttp'], - note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.', - knownFailures: [ - { - note: "Server.createMessage (src/server/index.ts ~line 497) only gates tools/toolChoice on clientCapabilities.sampling.tools; there is no check of clientCapabilities.sampling.context for includeContext 'thisServer'/'allServers', so the request reaches the client callback instead of being refused." - } - ] + note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' }, 'sampling:create:not-supported': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#capabilities', diff --git a/test/e2e/scenarios/sampling.test.ts b/test/e2e/scenarios/sampling.test.ts index c8048aad3b..004b8b602b 100644 --- a/test/e2e/scenarios/sampling.test.ts +++ b/test/e2e/scenarios/sampling.test.ts @@ -107,7 +107,8 @@ verifies('sampling:create:basic', async ({ transport }: TestArgs) => { verifies('sampling:create:include-context', async ({ transport }: TestArgs) => { const received: CreateMessageRequest[] = []; - const client = newClient(); + // thisServer and allServers are only sent to a client that declares sampling.context. + const client = newClient({ sampling: { context: {} } }); client.setRequestHandler('sampling/createMessage', async req => { received.push(req); return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } }; diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts index 0d5faabbe1..4fff0c72a3 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -1540,6 +1540,60 @@ describe('createMessage validation', () => { ).rejects.toThrow('Client does not support sampling tools capability.'); }); + test('should throw when includeContext thisServer or allServers is used without sampling.context capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client( + { name: 'test client', version: '1.0' }, + { capabilities: { sampling: {} } } // No context capability + ); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + for (const includeContext of ['thisServer', 'allServers'] as const) { + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + includeContext + }) + ).rejects.toThrow('Client does not support sampling context capability.'); + } + }); + + test('should allow includeContext thisServer on a 2025-06-18 connection, which predates sampling.context', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client( + { name: 'test client', version: '1.0' }, + { capabilities: { sampling: {} }, supportedProtocolVersions: ['2025-06-18'] } + ); + + client.setRequestHandler('sampling/createMessage', async request => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: `includeContext=${request.params.includeContext}` } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-06-18'); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + includeContext: 'thisServer' + }); + expect(result.content).toEqual({ type: 'text', text: 'includeContext=thisServer' }); + }); + test('should throw when tool_result is mixed with other content', async () => { const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });