fix(http-base): return valid empty results for unhandled list methods (#476)

The BaseHttpScenario generic fallback answered any unrouted request
with only the required draft result fields, so a standard list method
a scenario does not route came back without its required list member.
http-standard-headers advertises resources but has no
resources/templates/list route; the reply's missing resourceTemplates
fails schema validation (draft and 2025-11-25 both require it), and
strictly-validating clients drop the connection before the scenario's
header checks run.

Merge schema-valid empty results for the six list-shaped methods
(tools, resources, resource templates, prompts, roots, tasks) into
the fallback, keyed by a ReadonlyMap so method names that collide
with Object.prototype ("constructor", "toString", ...) miss instead
of poisoning the reply with a prototype function. tasks/list has a
typed result only at 2025-11-25; the empty member is harmless on the
draft wire. Non-list results (tools/call, resources/read,
prompts/get, ...) have no meaningful empty default and keep the bare
stamped fallback, so a route a scenario forgot surfaces instead of
being masked.

Tests pin the fixed behaviour on all three http scenario classes,
the prototype-name lookups, and the spec schema's rejection of the
pre-fix payload.

Fixes #474
This commit is contained in:
Yusufhan Sacak 2026-09-06 20:50:05 +03:00 • committed by GitHub
commit 7a702f5a11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 195 additions and 3 deletions

View file

@ -1,11 +1,14 @@
import { describe, it, expect } from 'vitest';
import { testScenarioContext } from '../../mock-server/testing';
import { withRequiredDraftResultFields } from '../../mock-server';
import { wireSchemaErrors } from '../../validation/wire-schema';
import { DRAFT_PROTOCOL_VERSION } from '../../types';
import { HttpStandardHeadersScenario } from './http-standard-headers';
import {
HttpCustomHeadersScenario,
HttpInvalidToolHeadersScenario
} from './http-custom-headers';
import type { BaseHttpScenario } from './http-base';
import { RequestMetadataScenario } from './request-metadata';
import { MRTRClientScenario } from './mrtr-client';
import { JsonSchemaRefDerefScenario } from './json-schema-ref-deref';
@ -134,7 +137,8 @@ describe('http-standard-headers mock results (2026-07-28)', () => {
cacheable: true
},
// Not explicitly handled by the scenario — exercises the method-aware
// generic fallback, which must still add the caching hints.
// generic fallback, which must still add the caching hints and the
// required (empty) list member.
{ method: 'resources/templates/list', cacheable: true },
{ method: 'prompts/list', cacheable: true },
{
@ -171,6 +175,167 @@ describe('http-standard-headers mock results (2026-07-28)', () => {
});
});
describe('generic fallback answers unrouted list methods schema-valid (#474)', () => {
// http-standard-headers advertises `resources` but has no
// resources/templates/list route; the request falls through to the generic
// fallback in BaseHttpScenario. Before the fix the reply had no
// `resourceTemplates` member, so strictly-validating clients (e.g.
// cloudflare/agents MCPClientManager) dropped the connection before the
// scenario's header checks ran.
it('http-standard-headers answers resources/templates/list with a valid empty result', async () => {
const scenario = new HttpStandardHeadersScenario();
const { serverUrl } = await scenario.start(
testScenarioContext(DRAFT_PROTOCOL_VERSION)
);
try {
const { status, body } = await post(
serverUrl,
{
jsonrpc: '2.0',
id: 1,
method: 'resources/templates/list',
params: {}
},
{ 'Mcp-Method': 'resources/templates/list' }
);
expect(status).toBe(200);
expect(body.result).toEqual({
...CACHEABLE_FIELDS,
resourceTemplates: []
});
expect(
wireSchemaErrors(
DRAFT_PROTOCOL_VERSION,
body,
'resources/templates/list'
)
).toEqual([]);
} finally {
await scenario.stop();
}
});
// Every list-shaped method a scenario does not route must reach the
// fallback and come back with its required (empty) list member, on all
// three BaseHttpScenario subclasses. http-standard-headers routes the
// tools/resources/prompts lists itself; the custom-headers scenarios route
// only initialize and tools/*. tasks/list has no typed result in the draft
// schema (2025-11-25 only), so it is additionally validated at 2025-11-25.
const listMember = new Map([
['resources/list', 'resources'],
['resources/templates/list', 'resourceTemplates'],
['prompts/list', 'prompts'],
['roots/list', 'roots'],
['tasks/list', 'tasks']
]);
const subclasses: Array<{
name: string;
make: () => BaseHttpScenario;
unrouted: string[];
}> = [
{
name: 'http-standard-headers',
make: () => new HttpStandardHeadersScenario(),
unrouted: ['resources/templates/list', 'roots/list', 'tasks/list']
},
{
name: 'http-custom-headers',
make: () => new HttpCustomHeadersScenario(),
unrouted: [...listMember.keys()]
},
{
name: 'http-invalid-tool-headers',
make: () => new HttpInvalidToolHeadersScenario(),
unrouted: [...listMember.keys()]
}
];
for (const s of subclasses) {
it(`${s.name} answers unrouted list methods with valid empty results`, async () => {
const scenario = s.make();
const { serverUrl } = await scenario.start(
testScenarioContext(DRAFT_PROTOCOL_VERSION)
);
try {
let id = 1;
for (const method of s.unrouted) {
const { status, body } = await post(
serverUrl,
{ jsonrpc: '2.0', id: id++, method, params: {} },
{ 'Mcp-Method': method }
);
expect(status, method).toBe(200);
expect(body.result[listMember.get(method)!], method).toEqual([]);
expect(
wireSchemaErrors(DRAFT_PROTOCOL_VERSION, body, method),
method
).toEqual([]);
if (method === 'tasks/list') {
expect(wireSchemaErrors('2025-11-25', body, method)).toEqual([]);
}
}
} finally {
await scenario.stop();
}
});
}
// Method names that collide with Object.prototype must miss the empty-list
// lookup and get the plain generic result — an object-literal map here once
// returned Object.prototype functions, which JSON.stringify drops, leaving
// a reply with neither result nor error.
for (const s of subclasses) {
it(`${s.name} answers Object.prototype-colliding method names with a valid generic result`, async () => {
const scenario = s.make();
const { serverUrl } = await scenario.start(
testScenarioContext(DRAFT_PROTOCOL_VERSION)
);
try {
let id = 1;
for (const method of [
'constructor',
'toString',
'valueOf',
'hasOwnProperty'
]) {
const { status, body } = await post(
serverUrl,
{ jsonrpc: '2.0', id: id++, method, params: {} },
{ 'Mcp-Method': method }
);
expect(status, method).toBe(200);
expect(body.result, method).toEqual({ resultType: 'complete' });
expect(
wireSchemaErrors(DRAFT_PROTOCOL_VERSION, body, method),
method
).toEqual([]);
}
} finally {
await scenario.stop();
}
});
}
// Regression pin: the pre-fix fallback payload (required draft fields only,
// no list member) is rejected by the spec schema — proves the assertions
// above have teeth.
it('rejects the pre-fix bare fallback result for resources/templates/list', () => {
const preFix = {
jsonrpc: '2.0',
id: 1,
result: withRequiredDraftResultFields('resources/templates/list', {})
};
const errors = wireSchemaErrors(
DRAFT_PROTOCOL_VERSION,
preFix,
'resources/templates/list'
);
expect(errors.join('\n')).toContain(
"must have required property 'resourceTemplates'"
);
});
});
describe('http-custom-headers mock results (2026-07-28)', () => {
it('carries the draft-required result members', async () => {
const scenario = new HttpCustomHeadersScenario();

View file

@ -20,6 +20,29 @@ import {
DRAFT_PROTOCOL_VERSION
} from '../../types.js';
/**
* Schema-valid empty results for the standard list-shaped methods, keyed by
* method. A Map, not an object literal, so a method name that collides with
* Object.prototype ("constructor", "toString", ...) misses instead of
* returning a function. Merged into the generic fallback so a list method a
* scenario does not route still carries its required list member — a bare
* `{}` fails schema validation and strict clients drop the connection before
* the scenario's real checks run (#474). `tasks/list` exists only at
* 2025-11-25 (the draft schema has no ListTasksResult); the empty member is
* harmless on the draft wire. Non-list results (tools/call, resources/read,
* prompts/get, ...) have no meaningful empty default and keep the bare
* stamped fallback, so a route a scenario forgot surfaces instead of being
* masked.
*/
const EMPTY_LIST_RESULTS: ReadonlyMap<string, object> = new Map([
['tools/list', { tools: [] }],
['resources/list', { resources: [] }],
['resources/templates/list', { resourceTemplates: [] }],
['prompts/list', { prompts: [] }],
['roots/list', { roots: [] }],
['tasks/list', { tasks: [] }]
]);
export abstract class BaseHttpScenario implements Scenario {
abstract name: string;
abstract description: string;
@ -181,8 +204,12 @@ export abstract class BaseHttpScenario implements Scenario {
jsonrpc: '2.0',
id: request.id,
// 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, {})
// reply still carry the ttlMs/cacheScope the draft revision requires,
// and unrouted standard list methods carry their required list member.
result: withRequiredDraftResultFields(
request.method,
EMPTY_LIST_RESULTS.get(request.method) ?? {}
)
});
}
}