fix(core): restore the v1 content default for tools/call results on the legacy era (#2456)

This commit is contained in:
Felix Weinberger 2026-07-09 14:02:08 +01:00 • committed by GitHub
commit 44797d7779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 347 additions and 96 deletions

View file

@ -4,7 +4,8 @@ import {
ClientResultSchema,
ServerNotificationSchema,
ServerRequestSchema,
ServerResultSchema
ServerResultSchema,
TOOL_RESULT_FOREIGN_FAMILY_KEYS
} from '@modelcontextprotocol/core-internal';
import type { Transport } from '@modelcontextprotocol/server';
import {
@ -16,6 +17,10 @@ import {
isSpecType
} from '@modelcontextprotocol/server';
function isPlainRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
export type WireParty = 'client' | 'server';
export interface SnifferOptions {
@ -100,7 +105,28 @@ export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferO
// (InputRequiredResultSchema lives alongside, never widening it).
// Era-gated: only cells wired for the modern era opt in, so an
// input_required on a 2025-era cell's wire is still flagged.
if (party === 'server' && opts.allowInputRequiredResults === true && isInputRequiredResult(result)) return;
if (party === 'server' && isInputRequiredResult(result)) {
if (opts.allowInputRequiredResults === true) return;
fail(party, `input_required result on a cell not opted into multi-round-trip`, msg);
}
// With content.default([]) restored, the neutral union accepts any
// object via the CallToolResult member — so union conformance below
// is a weak check, and the other result families are asserted here
// explicitly, by vocabulary. Cells that opt into vendor-extension
// methods skip this check (the sniffer is method-blind for results;
// before the default was restored these bodies failed the union parse
// and were excused by the same flag).
if (party === 'server' && !opts.allowCustomMethods && isPlainRecord(result) && result.content === undefined) {
for (const key of TOOL_RESULT_FOREIGN_FAMILY_KEYS) {
if (key in result) {
fail(
party,
`content-less result carrying '${key}' — another result family must not read as an empty tools/call success`,
msg
);
}
}
}
const r = schemas.result.safeParse(result);
if (!r.success) {
// A result for a vendor-extension request legitimately won't match the spec union.

View file

@ -14,10 +14,10 @@
* ABSENT `resultType` is a spec violation surfaced as a typed error
* naming it.
* - Negotiated legacy (2025 era): `resultType` is FOREIGN vocabulary —
* strip-on-lift (Q1-SD3 ii; a deliberate, ledgered change from the
* pre-split era-blind rejection — changeset: codec-split-wire-break). The
* stripped body then fails the (default-free) result schema loudly
* because it has no content.
* strip-on-lift (Q1-SD3 ii). The stripped husk still carries the
* input_required family keys, so the wire-seam schema rejects it loudly
* (a content-less body without family keys would default to content: []
* — changeset: calltoolresult-content-default).
*
* Either way the V-1 invariant holds: never an empty-content success.
*/
@ -115,8 +115,8 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr
try {
const outcome = await callToolOutcome(client);
// Strip-on-lift (Q1-SD3 ii, ledgered): the foreign resultType is
// dropped; the body has no content, so validation fails LOUDLY.
// Strip-on-lift: the foreign resultType is dropped; the husk's
// input_required family keys fail the wire-seam schema LOUDLY.
// Never an empty-content success.
expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false);
const rejection = (outcome as { rejected: unknown }).rejected;

View file

@ -1828,8 +1828,6 @@ describe('outputSchema validation', () => {
server.setRequestHandler('tools/call', async request => {
if (request.params.name === 'test-tool') {
return {
// content is spec-required (the wire default([]) was removed
// - ledgered; changeset codec-split-wire-break)
content: [],
structuredContent: { result: 'success', count: 42 }
};