From 99ac073f4bb126a151e7637b449562f4185dd690 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 14:02:09 +0000 Subject: [PATCH] fix: include required result members in 2026-07-28 mock responses At the 2026-07-28 revision every server result must include resultType, and cacheable results (server/discover, tools/list, prompts/list, resources/list, resources/templates/list, resources/read) must also include ttlMs and cacheScope. The client-direction mock servers omitted these, so a strictly-validating client was failed by the suite's own non-conformant mock. The shared stateless mock now stamps the missing members onto handler results and its server/discover response (withRequiredDraftResultFields), and the hand-rolled per-scenario servers set them explicitly or route their fallbacks through the helper. The deliberately resultType-less probe in sep-2322-client-request-state is unchanged. New tests pin the response shapes. --- src/mock-server/index.ts | 7 +- src/mock-server/mock-server.test.ts | 138 +++++++- src/mock-server/stateless.ts | 49 ++- .../client/auth/helpers/createServer.ts | 9 +- .../client/draft-result-fields.test.ts | 296 ++++++++++++++++++ src/scenarios/client/http-base.ts | 10 +- src/scenarios/client/http-custom-headers.ts | 8 + src/scenarios/client/http-standard-headers.ts | 14 + src/scenarios/client/json-schema-ref-deref.ts | 3 + src/scenarios/client/mrtr-client.ts | 21 +- src/scenarios/client/request-metadata.ts | 17 +- src/scenarios/client/tools_call.test.ts | 57 ++++ 12 files changed, 612 insertions(+), 17 deletions(-) create mode 100644 src/scenarios/client/draft-result-fields.test.ts diff --git a/src/mock-server/index.ts b/src/mock-server/index.ts index 7303cb83..7c8470e6 100644 --- a/src/mock-server/index.ts +++ b/src/mock-server/index.ts @@ -56,5 +56,10 @@ export interface ScenarioContext { } export { createServerStateful } from './stateful'; -export { createServerStateless, validateStatelessRequest } from './stateless'; +export { + createServerStateless, + validateStatelessRequest, + withRequiredDraftResultFields, + CACHEABLE_RESULT_METHODS +} from './stateless'; export { createServerFor } from './select'; diff --git a/src/mock-server/mock-server.test.ts b/src/mock-server/mock-server.test.ts index c0247f88..8af684ca 100644 --- a/src/mock-server/mock-server.test.ts +++ b/src/mock-server/mock-server.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect } from 'vitest'; import { createServerFor } from './select'; import { createServerStateful } from './stateful'; -import { createServerStateless, validateStatelessRequest } from './stateless'; +import { + createServerStateless, + validateStatelessRequest, + withRequiredDraftResultFields, + CACHEABLE_RESULT_METHODS +} from './stateless'; import { STATELESS_SPEC_VERSIONS } from '../connection/select'; import { DRAFT_PROTOCOL_VERSION } from '../types'; @@ -73,6 +78,28 @@ describe('validateStatelessRequest', () => { expect(v).toMatchObject({ kind: 'route', id: 1, method: 'tools/list' }); }); + it('includes the draft-required result members on the server/discover result', () => { + const v = validateStatelessRequest( + { + headers, + body: { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: meta } + } + }, + {}, + [DRAFT_PROTOCOL_VERSION] + ); + expect(v).toMatchObject({ + kind: 'handled', + body: { + result: { resultType: 'complete', ttlMs: 0, cacheScope: 'private' } + } + }); + }); + it('rejects versions outside the supported list with -32004 and echoes it', () => { const v = validateStatelessRequest( { @@ -105,6 +132,53 @@ describe('validateStatelessRequest', () => { }); }); +describe('withRequiredDraftResultFields', () => { + it('stamps resultType "complete" when the handler omitted it', () => { + expect( + withRequiredDraftResultFields('tools/call', { content: [] }) + ).toEqual({ resultType: 'complete', content: [] }); + }); + + it('adds ttlMs and cacheScope for every cacheable method', () => { + for (const method of CACHEABLE_RESULT_METHODS) { + expect(withRequiredDraftResultFields(method, {})).toEqual({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + }); + } + }); + + it('does not add caching hints to non-cacheable results', () => { + const result = withRequiredDraftResultFields('tools/call', { + content: [] + }); + expect(result).not.toHaveProperty('ttlMs'); + expect(result).not.toHaveProperty('cacheScope'); + }); + + it('preserves members the handler set itself', () => { + expect( + withRequiredDraftResultFields('tools/call', { + resultType: 'input_required', + inputRequests: {} + }) + ).toMatchObject({ resultType: 'input_required' }); + expect( + withRequiredDraftResultFields('tools/list', { + ttlMs: 5000, + cacheScope: 'public', + tools: [] + }) + ).toMatchObject({ ttlMs: 5000, cacheScope: 'public' }); + }); + + it('passes non-object results through untouched', () => { + expect(withRequiredDraftResultFields('tools/call', null)).toBeNull(); + expect(withRequiredDraftResultFields('tools/call', [1])).toEqual([1]); + }); +}); + describe('createServerFor', () => { it('returns stateful for dated 2025-x versions', () => { expect(createServerFor('2025-06-18')).toBe(createServerStateful); @@ -286,6 +360,68 @@ describe('createServerStateless', () => { } }); + it('stamps the draft-required result members onto handler results', async () => { + const srv = await createServerStateless({ + 'tools/list': () => ({ tools: [{ name: 'x' }] }), + 'tools/call': () => ({ content: [{ type: 'text', text: 'ok' }] }) + }); + try { + const list = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }, + headers + ); + expect(list.body.result).toMatchObject({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [{ name: 'x' }] + }); + + const call = await post( + srv.url, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { _meta: meta, name: 'x' } + }, + headers + ); + expect(call.body.result).toMatchObject({ resultType: 'complete' }); + expect(call.body.result).not.toHaveProperty('ttlMs'); + expect(call.body.result).not.toHaveProperty('cacheScope'); + } finally { + await srv.close(); + } + }); + + it('preserves a resultType the handler set itself', async () => { + const srv = await createServerStateless({ + 'tools/call': () => ({ resultType: 'input_required', inputRequests: {} }) + }); + try { + const { body } = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { _meta: meta } + }, + headers + ); + expect(body.result.resultType).toBe('input_required'); + } finally { + await srv.close(); + } + }); + it('returns -32601 for unknown methods', async () => { const srv = await createServerStateless({}); try { diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts index 78fd6384..904d0e03 100644 --- a/src/mock-server/stateless.ts +++ b/src/mock-server/stateless.ts @@ -20,6 +20,45 @@ const META_KEYS = [ 'io.modelcontextprotocol/clientCapabilities' ] as const; +/** + * Operations whose results the 2026-07-28 revision marks cacheable: servers + * MUST include the caching hints `ttlMs` and `cacheScope` on these results + * (draft `CacheableResult`, server/utilities/caching). + */ +export const CACHEABLE_RESULT_METHODS: ReadonlySet = new Set([ + 'server/discover', + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'resources/read' +]); + +/** + * Fill in the result members the 2026-07-28 revision requires of servers when + * the handler did not set them itself: every result MUST carry `resultType`, + * and results of the cacheable operations MUST also carry `ttlMs` and + * `cacheScope`. Members the handler set are preserved (e.g. a handler may + * return `resultType: 'input_required'`); only absent (or undefined) ones are + * filled. A scenario that needs to send a deliberately non-conformant result + * must build its own server instead of routing through this mock. + */ +export function withRequiredDraftResultFields( + method: string, + result: unknown +): unknown { + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return result; + } + const stamped: Record = { ...result }; + stamped.resultType ??= 'complete'; + if (CACHEABLE_RESULT_METHODS.has(method)) { + stamped.ttlMs ??= 0; + stamped.cacheScope ??= 'private'; + } + return stamped; +} + type IncomingHeaders = Record; export type StatelessValidation = @@ -114,11 +153,11 @@ export function validateStatelessRequest( body: { jsonrpc: '2.0', id, - result: { + result: withRequiredDraftResultFields(method, { supportedVersions, capabilities, serverInfo: { name: 'conformance-mock-server', version: '1.0.0' } - } + }) } }; } @@ -166,7 +205,11 @@ export async function createServerStateless( } try { const result = await handler(params, req.body as JSONRPCRequest); - return res.json({ jsonrpc: '2.0', id, result }); + return res.json({ + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, result) + }); } catch (e) { return error(500, -32603, e instanceof Error ? e.message : String(e)); } diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 8cf04d97..cd32d21b 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -12,6 +12,7 @@ import express, { Request, Response, NextFunction } from 'express'; import type { ConformanceCheck } from '../../../../types'; import { validateStatelessRequest, + withRequiredDraftResultFields, type ScenarioContext } from '../../../../mock-server'; import { isStatefulVersion } from '../../../../connection/select'; @@ -210,16 +211,18 @@ export function createServer( return res.json({ jsonrpc: '2.0', id, - result: { + result: withRequiredDraftResultFields(method, { tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] - } + }) }); } if (method === 'tools/call') { return res.json({ jsonrpc: '2.0', id, - result: { content: [{ type: 'text', text: 'test' }] } + result: withRequiredDraftResultFields(method, { + content: [{ type: 'text', text: 'test' }] + }) }); } return res.status(404).json({ diff --git a/src/scenarios/client/draft-result-fields.test.ts b/src/scenarios/client/draft-result-fields.test.ts new file mode 100644 index 00000000..3d5c37ae --- /dev/null +++ b/src/scenarios/client/draft-result-fields.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from 'vitest'; +import { testScenarioContext } from '../../mock-server/testing'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; +import { HttpStandardHeadersScenario } from './http-standard-headers'; +import { + HttpCustomHeadersScenario, + HttpInvalidToolHeadersScenario +} from './http-custom-headers'; +import { RequestMetadataScenario } from './request-metadata'; +import { MRTRClientScenario } from './mrtr-client'; + +/** + * Pins that the hand-rolled mock servers used by client-direction scenarios + * at 2026-07-28 return spec-valid results: every result carries `resultType`, + * and the cacheable list/read/discover results also carry `ttlMs` and + * `cacheScope`. Without these, a strictly-conforming client is failed by the + * suite's own non-conformant mock. The shared stateless mock is covered in + * src/mock-server/mock-server.test.ts. + */ + +const meta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +async function post( + url: string, + body: object, + headers: Record = {} +) { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body) + }); + return { status: r.status, body: await r.json() }; +} + +const CACHEABLE_FIELDS = { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' +}; + +describe('http-standard-headers mock results (2026-07-28)', () => { + it('carries the draft-required result members on every handled method', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const cases: Array<{ + method: string; + params?: object; + cacheable: boolean; + }> = [ + { method: 'initialize', cacheable: false }, + { method: 'tools/list', cacheable: true }, + { + method: 'tools/call', + params: { name: 'test_headers', arguments: {} }, + cacheable: false + }, + { method: 'resources/list', cacheable: true }, + { + method: 'resources/read', + params: { uri: 'file:///path/to/file%20name.txt' }, + cacheable: true + }, + // Not explicitly handled by the scenario — exercises the method-aware + // generic fallback, which must still add the caching hints. + { method: 'resources/templates/list', cacheable: true }, + { method: 'prompts/list', cacheable: true }, + { + method: 'prompts/get', + params: { name: 'test_prompt' }, + cacheable: false + }, + { method: 'unknown/method', cacheable: false } + ]; + let id = 1; + for (const c of cases) { + const { status, body } = await post( + serverUrl, + { + jsonrpc: '2.0', + id: id++, + method: c.method, + params: c.params ?? {} + }, + { 'Mcp-Method': c.method } + ); + expect(status, c.method).toBe(200); + expect(body.result.resultType, c.method).toBe('complete'); + if (c.cacheable) { + expect(body.result, c.method).toMatchObject({ + ttlMs: 0, + cacheScope: 'private' + }); + } + } + } finally { + await scenario.stop(); + } + }); +}); + +describe('http-custom-headers mock results (2026-07-28)', () => { + it('carries the draft-required result members', async () => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const list = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }); + expect(list.body.result).toMatchObject(CACHEABLE_FIELDS); + + const call = await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'test_custom_headers', + arguments: { region: 'us-east', priority: 1, query: 'q' } + } + }); + expect(call.body.result.resultType).toBe('complete'); + } finally { + await scenario.stop(); + } + }); +}); + +describe('http-invalid-tool-headers mock results (2026-07-28)', () => { + it('carries the draft-required result members', async () => { + const scenario = new HttpInvalidToolHeadersScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const list = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }); + expect(list.body.result).toMatchObject(CACHEABLE_FIELDS); + + const call = await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'valid_tool', arguments: { region: 'us-east' } } + }); + expect(call.body.result.resultType).toBe('complete'); + } finally { + await scenario.stop(); + } + }); +}); + +describe('request-metadata mock results (2026-07-28)', () => { + it('carries the draft-required result members after the simulated rejection', async () => { + const scenario = new RequestMetadataScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + const headers = { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; + try { + // The first request is always answered with the simulated -32004 + // rejection (retry probe); results are served from the second on. + const first = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }, + headers + ); + expect(first.status).toBe(400); + + const discover = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 2, + method: 'server/discover', + params: { _meta: meta } + }, + headers + ); + expect(discover.body.result).toMatchObject(CACHEABLE_FIELDS); + + const list = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/list', + params: { _meta: meta } + }, + headers + ); + expect(list.body.result).toMatchObject(CACHEABLE_FIELDS); + + const call = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { _meta: meta, name: 'x' } + }, + headers + ); + expect(call.body.result.resultType).toBe('complete'); + + const other = await post( + serverUrl, + { jsonrpc: '2.0', id: 5, method: 'ping', params: { _meta: meta } }, + headers + ); + expect(other.body.result.resultType).toBe('complete'); + } finally { + await scenario.stop(); + } + }); +}); + +describe('sep-2322-client-request-state mock results (2026-07-28)', () => { + it('carries the draft-required result members on conformant results and keeps the deliberate omission', async () => { + const scenario = new MRTRClientScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const list = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }); + expect(list.body.result).toMatchObject(CACHEABLE_FIELDS); + + const initial = await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'test_mrtr_echo_state', arguments: {} } + }); + expect(initial.body.result.resultType).toBe('input_required'); + + const retry = await post(serverUrl, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'test_mrtr_echo_state', + arguments: {}, + inputResponses: { confirm: { action: 'accept' } }, + requestState: initial.body.result.requestState + } + }); + expect(retry.body.result.resultType).toBe('complete'); + + const unrelated = await post(serverUrl, { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'test_mrtr_unrelated', arguments: {} } + }); + expect(unrelated.body.result.resultType).toBe('complete'); + + // The default-resultType probe deliberately omits resultType — that is + // the check's stimulus and must not be "fixed". + const noResultType = await post(serverUrl, { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'test_mrtr_no_result_type', arguments: {} } + }); + expect(noResultType.body.result.content).toBeDefined(); + expect(noResultType.body.result).not.toHaveProperty('resultType'); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/http-base.ts b/src/scenarios/client/http-base.ts index 9f6187d3..866ed65a 100644 --- a/src/scenarios/client/http-base.ts +++ b/src/scenarios/client/http-base.ts @@ -1,4 +1,7 @@ -import type { ScenarioContext } from '../../mock-server'; +import { + withRequiredDraftResultFields, + type ScenarioContext +} from '../../mock-server'; /** * Shared HTTP test-server scaffold for client-under-test SEP-2243 scenarios. * @@ -135,6 +138,7 @@ export abstract class BaseHttpScenario implements Scenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', protocolVersion: DRAFT_PROTOCOL_VERSION, serverInfo: { name: this.name + '-server', version: '1.0.0' }, capabilities @@ -151,7 +155,9 @@ export abstract class BaseHttpScenario implements Scenario { this.sendJson(res, { jsonrpc: '2.0', id: request.id, - result: {} + // Method-aware so cacheable methods that fall through to the generic + // reply still carry the ttlMs/cacheScope the draft revision requires. + result: withRequiredDraftResultFields(request.method, {}) }); } } diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index 25e36610..b445dc01 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -276,6 +276,9 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ { name: 'test_custom_headers', @@ -591,6 +594,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'Custom headers test completed' }] } }); @@ -760,6 +764,9 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ // ── Valid tool (should be kept by client) ── { @@ -938,6 +945,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'Tool call received' }] } }); diff --git a/src/scenarios/client/http-standard-headers.ts b/src/scenarios/client/http-standard-headers.ts index fb527d00..dcc45f67 100644 --- a/src/scenarios/client/http-standard-headers.ts +++ b/src/scenarios/client/http-standard-headers.ts @@ -221,6 +221,9 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ { name: 'test_headers', @@ -259,6 +262,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', content: [ { type: 'text', @@ -281,6 +285,9 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', resources: [ { uri: 'file:///path/to/file%20name.txt', @@ -309,6 +316,9 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', contents: [] } }) @@ -326,6 +336,9 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', prompts: [ { name: 'test_prompt', @@ -348,6 +361,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', messages: [] } }) diff --git a/src/scenarios/client/json-schema-ref-deref.ts b/src/scenarios/client/json-schema-ref-deref.ts index a7b315e0..1783d57d 100644 --- a/src/scenarios/client/json-schema-ref-deref.ts +++ b/src/scenarios/client/json-schema-ref-deref.ts @@ -44,6 +44,9 @@ function createMcpServer(canaryUrl: string, onToolsListed: () => void): Server { server.setRequestHandler(ListToolsRequestSchema, async () => { onToolsListed(); return { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ { name: TOOL_NAME, diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts index 4521ca04..eaeb4143 100644 --- a/src/scenarios/client/mrtr-client.ts +++ b/src/scenarios/client/mrtr-client.ts @@ -96,7 +96,12 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { res.json({ jsonrpc: '2.0', id, - result: { tools: TOOLS } + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: TOOLS + } }); return; } @@ -141,7 +146,11 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { res.json({ jsonrpc: '2.0', id, - result: { action: 'accept', content: { confirmed: true } } + result: { + resultType: 'complete', + action: 'accept', + content: { confirmed: true } + } }); return; } @@ -258,6 +267,7 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { jsonrpc: '2.0', id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'echo-state-ok' }] } }); @@ -324,6 +334,7 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { jsonrpc: '2.0', id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'no-state-ok' }] } }); @@ -370,6 +381,7 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { jsonrpc: '2.0', id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'unrelated-ok' }] } }); @@ -403,7 +415,10 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { res.json({ jsonrpc: '2.0', id, - result: { content: [{ type: 'text', text: 'unexpected-retry' }] } + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'unexpected-retry' }] + } }); return; } diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 38576b5d..4d66f2cb 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -1,4 +1,7 @@ -import type { ScenarioContext } from '../../mock-server'; +import { + withRequiredDraftResultFields, + type ScenarioContext +} from '../../mock-server'; import http from 'http'; import { Scenario, @@ -319,11 +322,11 @@ export class RequestMetadataScenario implements Scenario { JSON.stringify({ jsonrpc: '2.0', id: request.id, - result: { + result: withRequiredDraftResultFields(request.method, { supportedVersions: [DRAFT_PROTOCOL_VERSION], capabilities: {}, serverInfo: { name: 'test', version: '1.0' } - } + }) }) ); return; @@ -337,7 +340,13 @@ export class RequestMetadataScenario implements Scenario { result = { content: [] }; } res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: withRequiredDraftResultFields(request.method, result) + }) + ); }); } } diff --git a/src/scenarios/client/tools_call.test.ts b/src/scenarios/client/tools_call.test.ts index b9fc42d1..ad6a58e6 100644 --- a/src/scenarios/client/tools_call.test.ts +++ b/src/scenarios/client/tools_call.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { ToolsCallScenario } from './tools_call'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; describe('tools_call scenario', () => { it('emits a single FAILURE check when the tool was never called', async () => { @@ -20,6 +21,62 @@ describe('tools_call scenario', () => { } }); + it('serves spec-valid results at the draft (2026-07-28) version', async () => { + const meta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': {} + }; + async function post(url: string, body: object) { + const r = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION + }, + body: JSON.stringify(body) + }); + return { status: r.status, body: await r.json() }; + } + + const scenario = new ToolsCallScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const list = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }); + expect(list.status).toBe(200); + expect(list.body.result).toMatchObject({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + }); + + const call = await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { _meta: meta, name: 'add_numbers', arguments: { a: 2, b: 3 } } + }); + expect(call.status).toBe(200); + expect(call.body.result.resultType).toBe('complete'); + + const checks = scenario.getChecks(); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('SUCCESS'); + } finally { + await scenario.stop(); + } + }); + it('emits SUCCESS after a valid tools/call and getChecks() is idempotent', async () => { const scenario = new ToolsCallScenario(); const { serverUrl } = await scenario.start(testScenarioContext());