fix: include required result members in 2026-07-28 mock responses (#341)
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.
This commit is contained in:
parent
28f83384ef
commit
b9041ea41b
12 changed files with 612 additions and 17 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string> = 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<string, unknown> = { ...result };
|
||||
stamped.resultType ??= 'complete';
|
||||
if (CACHEABLE_RESULT_METHODS.has(method)) {
|
||||
stamped.ttlMs ??= 0;
|
||||
stamped.cacheScope ??= 'private';
|
||||
}
|
||||
return stamped;
|
||||
}
|
||||
|
||||
type IncomingHeaders = Record<string, string | string[] | undefined>;
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
296
src/scenarios/client/draft-result-fields.test.ts
Normal file
296
src/scenarios/client/draft-result-fields.test.ts
Normal file
|
|
@ -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<string, string> = {}
|
||||
) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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, {})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue