From 0c7eb0202c284a49a3c5982aef40326da3ad9c96 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 7 Jul 2026 15:37:51 +0000 Subject: [PATCH 1/6] fix(core): restore the v1 content default for tools/call results on the legacy era MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed servers return structuredContent-only (and otherwise content-less) tool results — SDK v1 accepted that shape for years via content.default([]), and removing the default turned every such call into an INVALID_RESULT error before application code could run, with no way to opt out downstream. Restore the default on the neutral schema and the 2025 era. The silent-empty-success hazard that motivated the removal is closed at its actual source instead of at every legacy server's expense: - the 2025 codec's strip arm rejects a stripped foreign resultType body with no explicit content (an input_required round can never surface as a hollow success), and likewise rejects a bare task-shaped body — modern task vocabulary is not a legacy server omitting content; - the 2026-era wire schemas stay strict: revision-2026-07-28 servers have no legacy excuse. Authoring is unchanged — the TypeScript surface requires content, and a structured-only handler result now reaches the wire spec-valid as content: [] (v1 behavior) instead of erroring. Driven end-to-end over HTTP: a live tools/call answering {"structuredContent": ...} with no content resolves with content: []. --- .changeset/calltoolresult-content-default.md | 5 ++ docs/migration/upgrade-to-v2.md | 11 ++-- packages/core-internal/src/types/schemas.ts | 6 +- .../src/wire/rev2025-11-25/codec.ts | 29 ++++++++- .../src/wire/rev2025-11-25/schemas.ts | 6 +- .../src/wire/rev2026-07-28/schemas.ts | 3 + .../test/shared/contentDefaultParity.test.ts | 65 +++++++++++++++++++ .../test/shared/rawResultTypeFirst.test.ts | 7 +- packages/core-internal/test/types.test.ts | 17 +++-- .../test/types/schemaBoundaryPins.test.ts | 22 ++++--- .../test/types/specTypeSchema.test.ts | 22 ++++--- .../core-internal/test/wire/eraGates.test.ts | 46 ++++++------- packages/server/test/server/server.test.ts | 14 ++-- 13 files changed, 186 insertions(+), 67 deletions(-) create mode 100644 .changeset/calltoolresult-content-default.md create mode 100644 packages/core-internal/test/shared/contentDefaultParity.test.ts diff --git a/.changeset/calltoolresult-content-default.md b/.changeset/calltoolresult-content-default.md new file mode 100644 index 0000000000..3fdd3a85f7 --- /dev/null +++ b/.changeset/calltoolresult-content-default.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core-internal': patch +--- + +Restore the v1 parse tolerance for `CallToolResult.content` on the legacy era and the neutral layer: an inbound `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error before application code could run. Authoring is unchanged (the TypeScript surface still requires `content`; a structured-only handler result is defaulted and reaches the wire spec-valid as `content: []`). The silent-empty-success hazard the strictness guarded is closed at its actual source instead: the 2025-era codec rejects a stripped foreign `resultType` body or a bare task-shaped body that carries no explicit `content`, and the 2026-era wire schemas stay strict — modern-revision servers have no legacy excuse. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 89188aa893..01b701c4b3 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1673,10 +1673,13 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see #### Wire tightening (every era) -- **`CallToolResult.content` is required at the wire boundary.** The `content.default([])` - affordance was removed. Tool handlers MUST include `content` (the TypeScript surface - always required it; `content: []` is fine). A handler result without it is rejected - with `-32602`. +- **`CallToolResult.content` keeps the v1 parse tolerance.** An inbound result without + `content` defaults to `[]` (deployed servers omit it alongside `structuredContent`). + Authoring is unchanged: the TypeScript surface requires `content` on handler results + (`content: []` is fine). One sharpening remains: on the 2025 era, a foreign + `resultType` body that carries no explicit `content` is still rejected after the + strip — a modern peer's `input_required` round can never surface as a silent empty + success. - **`ElicitResult.content` values are typed and validated as `string | number | boolean | string[]`.** v1's TypeScript surface accepted `Record` content values; an elicitation handler returning arbitrary diff --git a/packages/core-internal/src/types/schemas.ts b/packages/core-internal/src/types/schemas.ts index 21960a6176..c7196cbf90 100644 --- a/packages/core-internal/src/types/schemas.ts +++ b/packages/core-internal/src/types/schemas.ts @@ -1381,8 +1381,12 @@ export const CallToolResultSchema = ResultSchema.extend({ * * If the `Tool` does not define an outputSchema, this field MUST be present in the result. * Required on the wire per the specification (it may be an empty array). + * + * Parse-tolerant: an absent `content` defaults to `[]` (matching SDK v1), + * so results from servers that omit it — typically alongside + * `structuredContent` — don't fail the whole call. */ - content: z.array(ContentBlockSchema), + content: z.array(ContentBlockSchema).default([]), /** * Structured tool output. diff --git a/packages/core-internal/src/wire/rev2025-11-25/codec.ts b/packages/core-internal/src/wire/rev2025-11-25/codec.ts index ed30e9c67b..e9f70892ce 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/codec.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/codec.ts @@ -26,6 +26,7 @@ * 2026-vocabulary code path in the 2025 codec, it exists on the decode side * only, and it deletes — never reads, maps, or emits — the foreign value. */ +import { SdkError, SdkErrorCode } from '../../errors/sdkErrors'; import type * as z from 'zod/v4'; import type { CallToolResult, Result } from '../../types/types'; @@ -103,15 +104,41 @@ export const rev2025Codec: WireCodec = { return { ...withText, structuredContent: { result: sc } }; }, - decodeResult(_method: string, raw: unknown): DecodedResult { + decodeResult(method: string, raw: unknown): DecodedResult { // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is // dropped before validation, whatever its value. There is no // discrimination on this era — `resultType` carries no meaning here. if (isPlainObject(raw) && 'resultType' in raw) { const stripped = { ...raw }; delete stripped['resultType']; + // V-1 (never a hollow success): the tools/call content default is + // wire tolerance for plain legacy results — a stripped foreign + // body (e.g. an input_required round from a modern peer) must + // still carry the era's members explicitly, or the default would + // surface the round as a silent empty success. + if (method === 'tools/call' && !('content' in stripped)) { + return { + kind: 'invalid', + error: new SdkError( + SdkErrorCode.InvalidResult, + 'Invalid result for tools/call: a foreign resultType was stripped and no content remains', + { resultType: (raw as Record)['resultType'] } + ) + }; + } return { kind: 'complete', result: toNeutralResult(stripped) }; } + // Same V-1 guard for the bare task shape: a body carrying `task` is + // modern task vocabulary, not a legacy server omitting content — the + // default must not mask it as an empty success. + if (method === 'tools/call' && isPlainObject(raw) && !('content' in raw) && 'task' in raw) { + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.InvalidResult, 'Invalid result for tools/call: a task-shaped body has no content', { + violation: 'task-shaped-without-content' + }) + }; + } return { kind: 'complete', result: toNeutralResult(raw) }; }, diff --git a/packages/core-internal/src/wire/rev2025-11-25/schemas.ts b/packages/core-internal/src/wire/rev2025-11-25/schemas.ts index 935e3b74cc..9c094bafce 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/schemas.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/schemas.ts @@ -1226,8 +1226,12 @@ export const CallToolResultSchema = ResultSchema.extend({ * * If the `Tool` does not define an outputSchema, this field MUST be present in the result. * Required on the wire per the specification (it may be an empty array). + * + * Parse-tolerant: an absent `content` defaults to `[]` (matching SDK v1), + * so results from servers that omit it — typically alongside + * `structuredContent` — don't fail the whole call. */ - content: z.array(ContentBlockSchema), + content: z.array(ContentBlockSchema).default([]), /** * An object containing structured tool output. diff --git a/packages/core-internal/src/wire/rev2026-07-28/schemas.ts b/packages/core-internal/src/wire/rev2026-07-28/schemas.ts index 02a1663142..01a9178e8f 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/schemas.ts @@ -745,6 +745,8 @@ export const PaginatedResultSchema = wireResult({ }); export const CallToolResultSchema = wireResult({ + // Strict on this era: 2026-07-28 servers have no legacy excuse — the + // v1-parity content default applies to the 2025 era and neutral layer only. content: z.array(ContentBlockSchema), structuredContent: z.unknown().optional(), isError: z.boolean().optional() @@ -1070,6 +1072,7 @@ function liftedResult(shape: T) { export const dispatchResultSchemas: { readonly [M in Rev2026RequestMethod]: z.ZodType } = { 'tools/call': liftedResult({ + // Strict on this era (see CallToolResultSchema above). content: z.array(ContentBlockSchema), structuredContent: z.unknown().optional(), isError: z.boolean().optional() diff --git a/packages/core-internal/test/shared/contentDefaultParity.test.ts b/packages/core-internal/test/shared/contentDefaultParity.test.ts new file mode 100644 index 0000000000..2615ab42cc --- /dev/null +++ b/packages/core-internal/test/shared/contentDefaultParity.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { SdkError } from '../../src/errors/sdkErrors'; +import type { BaseContext } from '../../src/shared/protocol'; +import { Protocol } from '../../src/shared/protocol'; +import type { JSONRPCRequest } from '../../src/types/index'; +import { isJSONRPCRequest } from '../../src/types/index'; +import { InMemoryTransport } from '../../src/util/inMemory'; + +class TestProtocolImpl extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** + * v1 parse-parity for `CallToolResult.content` on the legacy era: deployed + * servers omit `content` alongside `structuredContent`, and SDK v1 accepted + * that for years. The result resolves with `content: []` instead of failing + * the whole call — while task vocabulary without content stays a loud error + * (the T6 guard lives in the codec, not the schema). + */ +describe('CallToolResult content default (v1 parity)', () => { + async function respondWith(body: Record) { + const protocol = new TestProtocolImpl(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + serverTransport.onmessage = message => { + if (isJSONRPCRequest(message)) { + void serverTransport.send({ + jsonrpc: '2.0', + id: (message as JSONRPCRequest).id, + result: body + }); + } + }; + await serverTransport.start(); + await protocol.connect(clientTransport); + try { + return await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + } finally { + await protocol.close().catch(() => {}); + } + } + + it('a structured-only result resolves with content: []', async () => { + const result = (await respondWith({ structuredContent: { ok: true } })) as { + content: unknown; + structuredContent: unknown; + }; + expect(result.content).toEqual([]); + expect(result.structuredContent).toEqual({ ok: true }); + }); + + it('an entirely empty result resolves with content: []', async () => { + const result = (await respondWith({})) as { content: unknown }; + expect(result.content).toEqual([]); + }); + + it('a task-shaped body without content still fails loudly (T6 guard)', async () => { + await expect(respondWith({ task: { taskId: 't-1', status: 'working' } })).rejects.toBeInstanceOf(SdkError); + }); +}); diff --git a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts index 3e44c48ed7..76b35ba68c 100644 --- a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts +++ b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts @@ -140,9 +140,10 @@ describe('raw-first resultType handling — 2025 era (strip-on-lift, Q1-SD3 ii)' // BEHAVIOR MIGRATION (ledgered): pre-split, the era-blind funnel arm // rejected this with UnsupportedResultType on every leg. On the 2025 // era resultType carries no meaning — the ruled posture strips the - // foreign key and lets validation decide. The body has no content, - // so it fails the (default-free) tools/call result schema LOUDLY — - // the V-1 invariant (never a hollow success) holds. + // foreign key. With the content default restored (v1 parity), the + // schema alone would accept the stripped husk, so the strip arm + // itself rejects a foreign body with no explicit content — the V-1 + // invariant (never a hollow success) holds by explicit guard. const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); const outcome = await settle(protocol); diff --git a/packages/core-internal/test/types.test.ts b/packages/core-internal/test/types.test.ts index ba7adca0fb..b085f9e35a 100644 --- a/packages/core-internal/test/types.test.ts +++ b/packages/core-internal/test/types.test.ts @@ -296,12 +296,17 @@ describe('Types', () => { } }); - test('requires content: the empty-object result no longer parses (deliberate flip)', () => { - // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): content.default([]) - // was removed from the wire schema (the T6 silent-empty-success - // masking root). Content is spec-required in every revision. - // Changeset: codec-split-wire-break. - expect(CallToolResultSchema.safeParse({}).success).toBe(false); + test('tolerates absent content: the empty-object result parses with content [] (v1 parity restored)', () => { + // BEHAVIOR MIGRATION (reversal, ledgered): content.default([]) is + // back on the neutral layer and the 2025 era — deployed servers + // omit content alongside structuredContent. The T6 masking root + // is closed by vocabulary guards in the 2025 codec and by the + // strict 2026-era schemas instead. + const empty = CallToolResultSchema.safeParse({}); + expect(empty.success).toBe(true); + if (empty.success) { + expect(empty.data.content).toEqual([]); + } const result = CallToolResultSchema.safeParse({ content: [] }); expect(result.success).toBe(true); if (result.success) { diff --git a/packages/core-internal/test/types/schemaBoundaryPins.test.ts b/packages/core-internal/test/types/schemaBoundaryPins.test.ts index 5dd6a11727..2ca6da0db2 100644 --- a/packages/core-internal/test/types/schemaBoundaryPins.test.ts +++ b/packages/core-internal/test/types/schemaBoundaryPins.test.ts @@ -139,18 +139,20 @@ describe('typed result schemas are loose', () => { expect((parsed as Record).ttlMs).toBe(5); }); - test('CallToolResult requires content on the wire (the silent-empty-success default is gone)', () => { - // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): `content.default([])` - // was removed from the wire schema. The default was the T6 width-leak - // root: a task-shaped (or otherwise content-less) body parsed as a - // silent `{content: []}` success. Content is required by the spec in - // every revision; a content-less body now fails the parse LOUDLY. - // Changeset: codec-split-wire-break; docs/migration/upgrade-to-v2.md - // "Wire tightening (every era)". - expect(CallToolResultSchema.safeParse({ structuredContent: { ok: true } }).success).toBe(false); - const parsed = CallToolResultSchema.parse({ content: [], structuredContent: { ok: true } }); + test('CallToolResult tolerates absent content on the wire (defaults to [], v1 parity)', () => { + // BEHAVIOR MIGRATION (reversal, ledgered): `content.default([])` was + // removed in the codec split (T6 width-leak root: a task-shaped body + // parsed as a silent success), then RESTORED for ecosystem parity — + // real deployments omit `content` alongside `structuredContent`, and + // v1 tolerated that. The T6 leak stays closed at its actual source: + // the 2025 codec's strip-on-lift rejects a stripped foreign body with + // no explicit content (rawResultTypeFirst.test.ts), and the 2026 era + // discriminates task bodies on `resultType` before parse. + const parsed = CallToolResultSchema.parse({ structuredContent: { ok: true } }); expect(parsed.content).toEqual([]); expect(parsed.structuredContent).toEqual({ ok: true }); + const explicit = CallToolResultSchema.parse({ content: [], structuredContent: { ok: true } }); + expect(explicit.content).toEqual([]); }); test('CallToolResult preserves isError and sibling members through the parse', () => { diff --git a/packages/core-internal/test/types/specTypeSchema.test.ts b/packages/core-internal/test/types/specTypeSchema.test.ts index 3266b09521..f7c2efff47 100644 --- a/packages/core-internal/test/types/specTypeSchema.test.ts +++ b/packages/core-internal/test/types/specTypeSchema.test.ts @@ -90,20 +90,24 @@ describe('isSpecType', () => { } }); - it('CallToolResult requires content at the boundary (the wire default was removed)', () => { - // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): CallToolResultSchema - // lost `content.default([])` — the silent-empty-success masking root. - // The guard's input shape now requires content, matching the spec in - // every revision. Changeset: codec-split-wire-break. + it('CallToolResult tolerates absent content at the boundary (default restored, v1 parity)', () => { + // BEHAVIOR MIGRATION (reversal, ledgered): content.default([]) is + // back for ecosystem parity (servers omit content alongside + // structuredContent). The guard consequently accepts a content-less + // body, exactly as v1's did; the task-husk leak the removal targeted + // is closed in the 2025 codec's strip arm instead. const empty: unknown = {}; - expect(isSpecType.CallToolResult(empty)).toBe(false); + expect(isSpecType.CallToolResult(empty)).toBe(true); const v: unknown = { content: [] }; expect(isSpecType.CallToolResult(v)).toBe(true); if (isSpecType.CallToolResult(v)) { - expectTypeOf(v.content).toEqualTypeOf(); - expectTypeOf(v.content).not.toEqualTypeOf(); + // The guard narrows to the INPUT type (before defaults), so + // content is honestly optional here — the default only + // materializes on parse. + expectTypeOf(v.content).toEqualTypeOf(); } - void (0 as unknown as CallToolResult); + // The parsed/public type keeps content required (z.output). + expectTypeOf().toEqualTypeOf(); }); it('JSONValue / JSONObject — narrows to the JSON type, not unknown', () => { diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index 8ecd642a1e..80dfcfb9ba 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -574,36 +574,28 @@ describe('T6 width-leak killed at both roots', () => { expect(decoded.kind).toBe('invalid'); }); - test('2025 era: with the content default gone, a bare task-shaped body fails the plain schema loudly', async () => { + test('2025 era: a bare task-shaped body is rejected at decode — the content default cannot mask it', async () => { const { rev2025Codec } = await import('../../src/wire/rev2025-11-25/codec'); - const { CallToolResultSchema } = await import('../../src/wire/rev2025-11-25/schemas'); const decoded = rev2025Codec.decodeResult('tools/call', { task: { taskId: 't-1', status: 'working' } }); - expect(decoded.kind).toBe('complete'); - if (decoded.kind === 'complete') { - // The plain schema (which IS the registry entry — the result map - // is aligned to the typed map, no task-widened union): no - // default([]) means no silent {content: []} masking. - expect(CallToolResultSchema.safeParse(decoded.result).success).toBe(false); - } - // The GENERIC path agrees: the registry serves the same plain schema, - // so even a fully conforming CreateTaskResult body is a loud schema - // failure (surfaced as a typed INVALID_RESULT — see + // With the v1-parity content default restored on this era's schema, + // the codec itself rejects modern task vocabulary that arrives + // without explicit content — no silent {content: []} masking. + expect(decoded.kind).toBe('invalid'); + // The GENERIC path agrees: every inbound result crosses decodeResult + // before any schema, so even a fully conforming CreateTaskResult body + // is rejected there (surfaced as a typed INVALID_RESULT — see // test/shared/typedMapAlignment.test.ts). Task interop is the // explicit-schema overload, never a silent union member. - const { getResultSchema } = await import('../../src/wire/rev2025-11-25/registry'); - const plain = getResultSchema('tools/call'); - expect(plain).toBe(CallToolResultSchema); - expect( - plain!.safeParse({ - task: { - taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', - status: 'working', - createdAt: '2025-11-25T10:30:00Z', - lastUpdatedAt: '2025-11-25T10:30:05Z', - ttl: 60000, - pollInterval: 5000 - } - }).success - ).toBe(false); + const decodedFull = rev2025Codec.decodeResult('tools/call', { + task: { + taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', + status: 'working', + createdAt: '2025-11-25T10:30:00Z', + lastUpdatedAt: '2025-11-25T10:30:05Z', + ttl: 60000, + pollInterval: 5000 + } + }); + expect(decodedFull.kind).toBe('invalid'); }); }); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index f6c40602ae..ee0cb956c5 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -193,13 +193,17 @@ describe('Server', () => { return response; } - it('rejects a structured-only handler result (no content) with -32602 Invalid tools/call result', async () => { + it('defaults a structured-only handler result (no content) to content: [] on the wire (v1 parity)', async () => { + // The TypeScript surface requires `content`; at runtime a + // structured-only result (dynamic/JS callers) is defaulted rather + // than rejected, matching v1 — and the wire output is + // spec-valid: content is present as []. const response = await callToolOnServer({ structuredContent: { ok: true } } as unknown as CallToolResult); - const error = (response as { error?: { code: number; message: string } }).error; - expect(error).toBeDefined(); - expect(error!.code).toBe(-32602); - expect(error!.message).toContain('Invalid tools/call result'); + const result = (response as { result?: { content?: unknown; structuredContent?: unknown } }).result; + expect(result).toBeDefined(); + expect(result!.content).toEqual([]); + expect(result!.structuredContent).toEqual({ ok: true }); }); it('passes an authored-content result through to the wire', async () => { From 4f00b9135c6b33c34204aaee63dabe5295a85a7b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 7 Jul 2026 16:17:39 +0000 Subject: [PATCH 2/6] fix(core): guard the content default at the wire seam, not in the codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review + CI caught the first cut over-reaching: the codec-level bare-task guard fired before explicit-schema validation, breaking the documented 2025 task-interop overload (task IS this era's wire vocabulary), and the guard enumeration missed the other input_required members. Move the protection to where it belongs: the registry's tools/call entry is now a wire-seam schema that refuses to default content for a body carrying another result family's vocabulary (task, inputRequests, requestState — the era is frozen, so the list is complete), then pipes into the plain schema. The explicit-schema task-interop overload never consults that map and is pinned working by a new test; the strip-on-lift resultType guard stays in the codec where the strip knowledge lives. Server-side authoring is now era-independent: a content-less handler result is normalized to content: [] before era validation on every leg — previously the flipped test only covered the legacy leg while a 2026-negotiated connection still rejected the same handler with -32602. Also updates the stale registry/typedMapAlignment/server-test comments that described the removed strict-schema mechanism, and pins the wire-seam wrapper by reference in registryPins. --- .changeset/calltoolresult-content-default.md | 7 ++- docs/migration/upgrade-to-v2.md | 16 ++++--- .../src/wire/rev2025-11-25/codec.ts | 11 ----- .../src/wire/rev2025-11-25/registry.ts | 29 +++++++++++- .../test/shared/contentDefaultParity.test.ts | 29 ++++++++++-- .../test/shared/typedMapAlignment.test.ts | 16 +++---- .../test/types/registryPins.test.ts | 6 ++- .../core-internal/test/wire/eraGates.test.ts | 45 +++++++++++-------- packages/server/src/server/server.ts | 14 +++++- packages/server/test/server/server.test.ts | 15 ++++--- 10 files changed, 127 insertions(+), 61 deletions(-) diff --git a/.changeset/calltoolresult-content-default.md b/.changeset/calltoolresult-content-default.md index 3fdd3a85f7..0145930c43 100644 --- a/.changeset/calltoolresult-content-default.md +++ b/.changeset/calltoolresult-content-default.md @@ -1,5 +1,10 @@ --- '@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch --- -Restore the v1 parse tolerance for `CallToolResult.content` on the legacy era and the neutral layer: an inbound `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error before application code could run. Authoring is unchanged (the TypeScript surface still requires `content`; a structured-only handler result is defaulted and reaches the wire spec-valid as `content: []`). The silent-empty-success hazard the strictness guarded is closed at its actual source instead: the 2025-era codec rejects a stripped foreign `resultType` body or a bare task-shaped body that carries no explicit `content`, and the 2026-era wire schemas stay strict — modern-revision servers have no legacy excuse. +Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error before application code could run. + +The silent-empty-success hazard the strictness guarded is preserved where it matters: the 2025 era's wire-seam schema refuses to default `content` for a body carrying another result family's vocabulary (`task`, `inputRequests`, `requestState` — the era is frozen, so the list is complete), the strip-on-lift arm still rejects a stripped foreign `resultType` body with no explicit content, and the 2026-era wire schemas stay strict — modern-revision servers have no legacy excuse. Task interop through an explicit result schema is untouched. + +Server-side authoring is era-independent: a handler result without `content` (dynamic/JS callers — the TypeScript surface requires it) is normalized to `content: []` before era validation on every leg, reaching the wire spec-valid. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 01b701c4b3..8f72b9050f 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1673,13 +1673,15 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see #### Wire tightening (every era) -- **`CallToolResult.content` keeps the v1 parse tolerance.** An inbound result without - `content` defaults to `[]` (deployed servers omit it alongside `structuredContent`). - Authoring is unchanged: the TypeScript surface requires `content` on handler results - (`content: []` is fine). One sharpening remains: on the 2025 era, a foreign - `resultType` body that carries no explicit `content` is still rejected after the - strip — a modern peer's `input_required` round can never surface as a silent empty - success. +- **`CallToolResult.content` keeps the v1 parse tolerance on the legacy era.** An + inbound result without `content` defaults to `[]` (deployed servers omit it + alongside `structuredContent`); 2026-07-28 connections stay strict. Authoring is + unchanged and era-independent: the TypeScript surface requires `content` on handler + results, and a content-less handler result is normalized to `content: []` before it + reaches the wire. One sharpening remains: a content-less body carrying another + result family's vocabulary (a task handle, an `input_required` round, or a stripped + foreign `resultType`) is still rejected loudly — tolerance never turns a different + result kind into a silent empty success. - **`ElicitResult.content` values are typed and validated as `string | number | boolean | string[]`.** v1's TypeScript surface accepted `Record` content values; an elicitation handler returning arbitrary diff --git a/packages/core-internal/src/wire/rev2025-11-25/codec.ts b/packages/core-internal/src/wire/rev2025-11-25/codec.ts index e9f70892ce..843c788027 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/codec.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/codec.ts @@ -128,17 +128,6 @@ export const rev2025Codec: WireCodec = { } return { kind: 'complete', result: toNeutralResult(stripped) }; } - // Same V-1 guard for the bare task shape: a body carrying `task` is - // modern task vocabulary, not a legacy server omitting content — the - // default must not mask it as an empty success. - if (method === 'tools/call' && isPlainObject(raw) && !('content' in raw) && 'task' in raw) { - return { - kind: 'invalid', - error: new SdkError(SdkErrorCode.InvalidResult, 'Invalid result for tools/call: a task-shaped body has no content', { - violation: 'task-shaped-without-content' - }) - }; - } return { kind: 'complete', result: toNeutralResult(raw) }; }, diff --git a/packages/core-internal/src/wire/rev2025-11-25/registry.ts b/packages/core-internal/src/wire/rev2025-11-25/registry.ts index f2878d429e..95f8a50e48 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/registry.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/registry.ts @@ -21,7 +21,7 @@ * shells, `resultType`, the `_meta` envelope) has NO entry and NO code path * here — the inverse-leak guarantee is physical absence, not discipline. */ -import type * as z from 'zod/v4'; +import * as z from 'zod/v4'; import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types'; import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas'; @@ -106,6 +106,31 @@ type Rev2025TypedRequestMethod = Extract; // no key may fall outside it (no `tasks/*` entries — the task methods are // 2025-11-25 wire vocabulary with no SDK runtime; callers needing task // interop pass an explicit schema). +/** + * Wire-seam guard for the tools/call content default: a content-less body + * carrying another 2025 result family's vocabulary (task interop's `task`, + * input_required's `inputRequests`/`requestState`) must fail loudly, never + * default into a hollow `{content: []}` success. The era is frozen, so this + * key list is complete by construction. Task interop via an EXPLICIT result + * schema is unaffected — that overload never consults this map. + */ +const OTHER_RESULT_FAMILY_KEYS = ['task', 'inputRequests', 'requestState'] as const; +export const CallToolResultWireSchema = z + .unknown() + .superRefine((value, ctx) => { + if (typeof value !== 'object' || value === null || Array.isArray(value) || 'content' in value) return; + for (const key of OTHER_RESULT_FAMILY_KEYS) { + if (key in value) { + ctx.addIssue({ + code: 'custom', + message: `content is required when the body carries '${key}' — another result family cannot default into an empty tools/call success` + }); + return; + } + } + }) + .pipe(CallToolResultSchema); + const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType } = { ping: EmptyResultSchema, initialize: InitializeResultSchema, @@ -118,7 +143,7 @@ const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType { * (the T6 guard lives in the codec, not the schema). */ describe('CallToolResult content default (v1 parity)', () => { - async function respondWith(body: Record) { + async function respondWith(body: Record, resultSchema?: Parameters['request']>[1]) { const protocol = new TestProtocolImpl(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); serverTransport.onmessage = message => { @@ -39,7 +39,9 @@ describe('CallToolResult content default (v1 parity)', () => { await serverTransport.start(); await protocol.connect(clientTransport); try { - return await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + return resultSchema === undefined + ? await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) + : await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }, resultSchema); } finally { await protocol.close().catch(() => {}); } @@ -59,7 +61,28 @@ describe('CallToolResult content default (v1 parity)', () => { expect(result.content).toEqual([]); }); - it('a task-shaped body without content still fails loudly (T6 guard)', async () => { + it('a task-shaped body without content still fails loudly (wire-seam guard)', async () => { await expect(respondWith({ task: { taskId: 't-1', status: 'working' } })).rejects.toBeInstanceOf(SdkError); }); + + it('task interop via an explicit result schema still works — the guard never touches that overload', async () => { + const { CreateTaskResultSchema } = await import('../../src/wire/rev2025-11-25/schemas'); + const body = { + task: { + taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', + status: 'working', + createdAt: '2025-11-25T10:30:00Z', + lastUpdatedAt: '2025-11-25T10:30:05Z', + ttl: 60000, + pollInterval: 5000 + } + }; + const result = (await respondWith(body, CreateTaskResultSchema)) as { task: { taskId: string } }; + expect(result.task.taskId).toBe('786af6b0-2779-48ed-9cc1-b8a8a25b8a86'); + }); + + it('an input_required-shaped body without content still fails loudly (wire-seam guard)', async () => { + await expect(respondWith({ inputRequests: { r1: { method: 'elicitation/create' } } })).rejects.toBeInstanceOf(SdkError); + await expect(respondWith({ requestState: 'opaque-token' })).rejects.toBeInstanceOf(SdkError); + }); }); diff --git a/packages/core-internal/test/shared/typedMapAlignment.test.ts b/packages/core-internal/test/shared/typedMapAlignment.test.ts index eacf493e4d..1c84d62249 100644 --- a/packages/core-internal/test/shared/typedMapAlignment.test.ts +++ b/packages/core-internal/test/shared/typedMapAlignment.test.ts @@ -97,14 +97,14 @@ describe('task-shaped result bodies against the narrowed runtime map', () => { // FLIPPED PIN (Q1 increment 2, ledgered with the content-default // removal — changeset: codec-split-wire-break). The previous "Honest // pin, not an endorsement" recorded that CallToolResultSchema's - // content.default([]) swallowed ANY object — including a task body — - // as a content-empty success, which made the old union member - // unreachable and the map narrowing observationally invisible for - // tools/call. With `content` now REQUIRED at the wire boundary the - // masking surface is gone: a task body has no `content`, fails the - // plain schema, and surfaces as the same typed invalid-result error - // as sampling/elicit. The result-schema-strictness question the old - // pin deferred is hereby resolved: loud rejection. + // A bare content.default([]) would swallow ANY object — including a + // task body — as a content-empty success. The wire-seam schema (the + // registry's tools/call entry) restores the v1 default for plain + // results but refuses to default a body carrying another result + // family's vocabulary: a task body has no `content`, fails the + // wire-seam schema, and surfaces as the same typed invalid-result + // error as sampling/elicit. Loud rejection, tolerant defaults — + // both at once. const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); const rejection = await protocol diff --git a/packages/core-internal/test/types/registryPins.test.ts b/packages/core-internal/test/types/registryPins.test.ts index 9af70927f2..6e8a9d5c18 100644 --- a/packages/core-internal/test/types/registryPins.test.ts +++ b/packages/core-internal/test/types/registryPins.test.ts @@ -22,7 +22,7 @@ import { describe, expect, it } from 'vitest'; // Post-relocation home (Q1 increment-2 step 1): the pinned contents are // unchanged — only the module housing the registries moved. -import { getNotificationSchema, getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry'; +import { getNotificationSchema, getRequestSchema, CallToolResultWireSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry'; // The 2025 wire schemas are fully self-contained in the era's schema module: // every per-method schema the registry serves is a FROZEN 2025-11-25 copy so // the public/neutral layer can evolve (e.g. SEP-2106 widening) without @@ -130,7 +130,9 @@ const EXPECTED_RESULT_SCHEMAS = { 'resources/read': ReadResourceResultSchema, 'resources/subscribe': EmptyResultSchema, 'resources/unsubscribe': EmptyResultSchema, - 'tools/call': CallToolResultSchema, + // The wire-seam wrapper (content-default guard) IS the pinned entry — + // identity to the wrapper, which pipes into the plain schema. + 'tools/call': CallToolResultWireSchema, 'tools/list': ListToolsResultSchema, 'sampling/createMessage': CreateMessageResultWithToolsSchema, 'elicitation/create': ElicitResultSchema, diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index 80dfcfb9ba..fbf112cc40 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -574,28 +574,35 @@ describe('T6 width-leak killed at both roots', () => { expect(decoded.kind).toBe('invalid'); }); - test('2025 era: a bare task-shaped body is rejected at decode — the content default cannot mask it', async () => { + test('2025 era: a bare task-shaped body fails the wire-seam schema — the content default cannot mask it', async () => { const { rev2025Codec } = await import('../../src/wire/rev2025-11-25/codec'); const decoded = rev2025Codec.decodeResult('tools/call', { task: { taskId: 't-1', status: 'working' } }); - // With the v1-parity content default restored on this era's schema, - // the codec itself rejects modern task vocabulary that arrives - // without explicit content — no silent {content: []} masking. - expect(decoded.kind).toBe('invalid'); - // The GENERIC path agrees: every inbound result crosses decodeResult - // before any schema, so even a fully conforming CreateTaskResult body - // is rejected there (surfaced as a typed INVALID_RESULT — see + // Decode is a pass-through for bodies without resultType (task is + // this era's own vocabulary — explicit-schema interop must work); + // the WIRE-SEAM schema (registry entry) refuses to default content + // for a body carrying another result family's keys. + expect(decoded.kind).toBe('complete'); + // The registry's wire-seam schema (the plain per-method entry) then + // rejects the body: content is never defaulted for another result + // family's vocabulary — even a fully conforming CreateTaskResult + // (surfaced as a typed INVALID_RESULT — see // test/shared/typedMapAlignment.test.ts). Task interop is the // explicit-schema overload, never a silent union member. - const decodedFull = rev2025Codec.decodeResult('tools/call', { - task: { - taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', - status: 'working', - createdAt: '2025-11-25T10:30:00Z', - lastUpdatedAt: '2025-11-25T10:30:05Z', - ttl: 60000, - pollInterval: 5000 - } - }); - expect(decodedFull.kind).toBe('invalid'); + const { getResultSchema } = await import('../../src/wire/rev2025-11-25/registry'); + const wireSeam = getResultSchema('tools/call'); + expect( + wireSeam!.safeParse({ + task: { + taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', + status: 'working', + createdAt: '2025-11-25T10:30:00Z', + lastUpdatedAt: '2025-11-25T10:30:05Z', + ttl: 60000, + pollInterval: 5000 + } + }).success + ).toBe(false); + // While a plain content-less tool result still defaults: + expect(wireSeam!.safeParse({ structuredContent: { ok: true } }).success).toBe(true); }); }); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4151849494..a1b272ecee 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -524,7 +524,19 @@ export class Server extends Protocol { return result; } - const validationResult = codec.validateResult('tools/call', result); + // v1-parity authoring affordance, era-independent: a handler + // result without content (typically structuredContent-only from + // dynamic/JS callers — the TypeScript surface requires content) + // is normalized to content: [] before era validation, so the + // wire is spec-valid on every leg. Task-shaped results are not + // normalized — that vocabulary must not masquerade as a tool + // result. + const normalizedResult = + result !== null && typeof result === 'object' && !('content' in result) && !('task' in result) + ? { ...result, content: [] } + : result; + + const validationResult = codec.validateResult('tools/call', normalizedResult); if (!validationResult.ok) { throw new ProtocolError( validationResult.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams, diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index ee0cb956c5..5c1940e503 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -154,13 +154,14 @@ describe('Server', () => { }); }); - describe('tools/call handler-result validation (required content)', () => { - // Server-side pin for the documented wire break (docs/migration/upgrade-to-v2.md, - // "Wire tightening (every era)"): with the - // content.default([]) affordance removed, a handler result without - // `content` is rejected with -32602 `Invalid tools/call result` — - // never silently defaulted onto the wire — while an authored-content - // result passes through the wrapped handler untouched. + describe('tools/call handler-result validation (content default)', () => { + // Server-side pin for the v1-parity authoring affordance + // (docs/migration/upgrade-to-v2.md, "Wire tightening (every era)"): + // a handler result without `content` is normalized to `content: []` + // BEFORE era validation — era-independently, so the wire is + // spec-valid on every leg — while an authored-content result passes + // through the wrapped handler untouched. Task-shaped handler results + // are not normalized. async function callToolOnServer(result: CallToolResult): Promise { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/call', () => result); From 91c3e7d77299b88130de0ec4debbb046aeab2594 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 7 Jul 2026 16:48:28 +0000 Subject: [PATCH 3/6] test(e2e): flag input_required era-gating explicitly in the wire sniffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sniffer relied on the neutral result union rejecting input_required bodies by content strictness; with the legacy-era content default restored that shape parses, so the era-gate is asserted by vocabulary instead — which states the actual rule. --- test/e2e/helpers/wire-sniffer.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/e2e/helpers/wire-sniffer.ts b/test/e2e/helpers/wire-sniffer.ts index 4e2c26bea5..44644dcd05 100644 --- a/test/e2e/helpers/wire-sniffer.ts +++ b/test/e2e/helpers/wire-sniffer.ts @@ -100,7 +100,13 @@ 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; + // Explicit flag: the neutral union no longer rejects this shape + // by strictness (CallToolResult.content defaults on the legacy + // era), so the era-gating is asserted here, by vocabulary. + fail(party, `input_required result on a cell not opted into multi-round-trip`, msg); + } const r = schemas.result.safeParse(result); if (!r.success) { // A result for a vendor-extension request legitimately won't match the spec union. From e4dc30c7133b2054552d21a467ae2abce5fd1294 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 7 Jul 2026 17:06:11 +0000 Subject: [PATCH 4/6] fix(server): normalize with the full foreign-family vocabulary; unify all husk guarding at the wire seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: - The server-side authoring normalization excluded only task-shaped bodies, so a handler result carrying inputRequests/requestState (without the resultType discriminator) was normalized to content: [] and shipped as a silent empty success where it was previously a loud -32602. The wire-seam vocabulary list is now a shared constant (TOOL_RESULT_FOREIGN_FAMILY_KEYS) used by both the registry guard and the normalization, with a test. - The 2025 codec's strip-arm content check ran in the shared decode hop, so it also fired for explicit-schema callers — an interop CreateTaskResult body that additionally stamped resultType was rejected before the caller's schema ran, contradicting the interop guarantee. Deleted: the strip is a pass-through again and the wire-seam schema owns all husk guarding (the input_required fixture carries family vocabulary, so V-1 still holds; a husk that was ONLY a resultType key defaults like any payload-free body, v1 parity). New test pins explicit-schema interop with a stamped resultType. - support-2026-07-28.md no longer lists 'content required' among the v1-affecting tightenings; the dangling half-rewritten comment in typedMapAlignment is completed and its title updated. --- .changeset/calltoolresult-content-default.md | 2 +- docs/migration/support-2026-07-28.md | 8 ++++--- packages/core-internal/src/index.ts | 2 +- packages/core-internal/src/wire/codec.ts | 8 +++++++ .../src/wire/rev2025-11-25/codec.ts | 23 +++++-------------- .../src/wire/rev2025-11-25/registry.ts | 4 ++-- .../test/shared/contentDefaultParity.test.ts | 22 ++++++++++++++++-- .../test/shared/rawResultTypeFirst.test.ts | 9 ++++---- .../test/shared/typedMapAlignment.test.ts | 9 ++++---- .../test/types/schemaBoundaryPins.test.ts | 6 ++--- .../test/types/specTypeSchema.test.ts | 2 +- packages/server/src/server/server.ts | 13 +++++++---- packages/server/test/server/server.test.ts | 10 ++++++++ 13 files changed, 76 insertions(+), 42 deletions(-) diff --git a/.changeset/calltoolresult-content-default.md b/.changeset/calltoolresult-content-default.md index 0145930c43..ef8b3e43ac 100644 --- a/.changeset/calltoolresult-content-default.md +++ b/.changeset/calltoolresult-content-default.md @@ -5,6 +5,6 @@ Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error before application code could run. -The silent-empty-success hazard the strictness guarded is preserved where it matters: the 2025 era's wire-seam schema refuses to default `content` for a body carrying another result family's vocabulary (`task`, `inputRequests`, `requestState` — the era is frozen, so the list is complete), the strip-on-lift arm still rejects a stripped foreign `resultType` body with no explicit content, and the 2026-era wire schemas stay strict — modern-revision servers have no legacy excuse. Task interop through an explicit result schema is untouched. +The silent-empty-success hazard the strictness guarded is preserved where it matters: the 2025 era's wire-seam schema refuses to default `content` for a body carrying another result family's vocabulary (`task`, `inputRequests`, `requestState` — the era is frozen, so the list is complete), and the 2026-era wire schemas stay strict — modern-revision servers have no legacy excuse. Task interop through an explicit result schema is untouched (including bodies that also stamp a foreign `resultType`), and the server-side authoring normalization refuses the same foreign-family vocabulary. Server-side authoring is era-independent: a handler result without `content` (dynamic/JS callers — the TypeScript surface requires it) is normalized to `content: []` before era validation on every leg, reaching the wire spec-valid. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index a9d603d11a..c781aea0c2 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -340,9 +340,11 @@ If you were on a v2 alpha and consumed wire schemas directly: The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x impact** — these members did not exist before 2026-07-28. The neutral-model wire -tightening that **does** affect v1 code (`content` required, custom-handler `_meta` -passthrough, `specTypeSchemas` narrowing) is in -[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era). +tightening that **does** affect v1 code (custom-handler `_meta` passthrough, +`specTypeSchemas` narrowing) is in +[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era); +`CallToolResult.content` keeps its v1 default on the legacy era (2026-07-28 +connections require it explicitly). > **If you were on a v2 alpha:** the 2026-07-28 draft error codes were renumbered: > `HeaderMismatch` `-32001`→`-32020`, `MissingRequiredClientCapability` `-32003`→`-32021`, diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 058de16174..7534505b81 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -32,7 +32,7 @@ export * from './util/inMemory'; export * from './util/schema'; export * from './util/standardSchema'; export * from './util/zodCompat'; -export { codecForVersion, MODERN_WIRE_REVISION } from './wire/codec'; +export { codecForVersion, MODERN_WIRE_REVISION, TOOL_RESULT_FOREIGN_FAMILY_KEYS } from './wire/codec'; // Validator provider classes stay subpath-only. Re-exporting them here, even as // `type`, can make generated client/server root declarations advertise diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index 9efb9a12f3..48f7911d6e 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -131,6 +131,14 @@ export interface OutboundEnvelopeMaterial { readonly logLevel?: LoggingLevel; } +/** + * Result-family vocabulary that must never ride a content-less body into a + * defaulted `{content: []}` tools/call success: task interop's `task`, and + * input_required's `inputRequests`/`requestState`. Shared by the 2025 + * wire-seam schema and the server's authoring normalization. + */ +export const TOOL_RESULT_FOREIGN_FAMILY_KEYS = ['task', 'inputRequests', 'requestState'] as const; + /** Result decode outcomes — the raw-first discrimination (V-1) lives in `decodeResult`. */ export type DecodedResult = | { diff --git a/packages/core-internal/src/wire/rev2025-11-25/codec.ts b/packages/core-internal/src/wire/rev2025-11-25/codec.ts index 843c788027..7225607ce4 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/codec.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/codec.ts @@ -26,7 +26,6 @@ * 2026-vocabulary code path in the 2025 codec, it exists on the decode side * only, and it deletes — never reads, maps, or emits — the foreign value. */ -import { SdkError, SdkErrorCode } from '../../errors/sdkErrors'; import type * as z from 'zod/v4'; import type { CallToolResult, Result } from '../../types/types'; @@ -104,28 +103,18 @@ export const rev2025Codec: WireCodec = { return { ...withText, structuredContent: { result: sc } }; }, - decodeResult(method: string, raw: unknown): DecodedResult { + decodeResult(_method: string, raw: unknown): DecodedResult { // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is // dropped before validation, whatever its value. There is no // discrimination on this era — `resultType` carries no meaning here. + // Validation judges the husk: on the plain path the wire-seam schema + // (registry) refuses to default `content` for a body carrying another + // result family's vocabulary; explicit-schema callers own their own + // validation (task interop). A husk that was ONLY a resultType key + // defaults to an empty success — v1 parity for a payload-free body. if (isPlainObject(raw) && 'resultType' in raw) { const stripped = { ...raw }; delete stripped['resultType']; - // V-1 (never a hollow success): the tools/call content default is - // wire tolerance for plain legacy results — a stripped foreign - // body (e.g. an input_required round from a modern peer) must - // still carry the era's members explicitly, or the default would - // surface the round as a silent empty success. - if (method === 'tools/call' && !('content' in stripped)) { - return { - kind: 'invalid', - error: new SdkError( - SdkErrorCode.InvalidResult, - 'Invalid result for tools/call: a foreign resultType was stripped and no content remains', - { resultType: (raw as Record)['resultType'] } - ) - }; - } return { kind: 'complete', result: toNeutralResult(stripped) }; } return { kind: 'complete', result: toNeutralResult(raw) }; diff --git a/packages/core-internal/src/wire/rev2025-11-25/registry.ts b/packages/core-internal/src/wire/rev2025-11-25/registry.ts index 95f8a50e48..fc84b869d1 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/registry.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/registry.ts @@ -24,6 +24,7 @@ import * as z from 'zod/v4'; import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types'; +import { TOOL_RESULT_FOREIGN_FAMILY_KEYS } from '../codec'; import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas'; import { CallToolRequestSchema, @@ -114,12 +115,11 @@ type Rev2025TypedRequestMethod = Extract; * key list is complete by construction. Task interop via an EXPLICIT result * schema is unaffected — that overload never consults this map. */ -const OTHER_RESULT_FAMILY_KEYS = ['task', 'inputRequests', 'requestState'] as const; export const CallToolResultWireSchema = z .unknown() .superRefine((value, ctx) => { if (typeof value !== 'object' || value === null || Array.isArray(value) || 'content' in value) return; - for (const key of OTHER_RESULT_FAMILY_KEYS) { + for (const key of TOOL_RESULT_FOREIGN_FAMILY_KEYS) { if (key in value) { ctx.addIssue({ code: 'custom', diff --git a/packages/core-internal/test/shared/contentDefaultParity.test.ts b/packages/core-internal/test/shared/contentDefaultParity.test.ts index 09672fb9c5..25458c923b 100644 --- a/packages/core-internal/test/shared/contentDefaultParity.test.ts +++ b/packages/core-internal/test/shared/contentDefaultParity.test.ts @@ -20,8 +20,9 @@ class TestProtocolImpl extends Protocol { * v1 parse-parity for `CallToolResult.content` on the legacy era: deployed * servers omit `content` alongside `structuredContent`, and SDK v1 accepted * that for years. The result resolves with `content: []` instead of failing - * the whole call — while task vocabulary without content stays a loud error - * (the T6 guard lives in the codec, not the schema). + * the whole call — while another result family's vocabulary without content + * stays a loud error (the guard lives in the wire-seam schema, the + * registry's tools/call entry). */ describe('CallToolResult content default (v1 parity)', () => { async function respondWith(body: Record, resultSchema?: Parameters['request']>[1]) { @@ -81,6 +82,23 @@ describe('CallToolResult content default (v1 parity)', () => { expect(result.task.taskId).toBe('786af6b0-2779-48ed-9cc1-b8a8a25b8a86'); }); + it('explicit-schema task interop resolves even when the body also stamps a foreign resultType', async () => { + const { CreateTaskResultSchema } = await import('../../src/wire/rev2025-11-25/schemas'); + const body = { + resultType: 'complete', + task: { + taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', + status: 'working', + createdAt: '2025-11-25T10:30:00Z', + lastUpdatedAt: '2025-11-25T10:30:05Z', + ttl: 60000, + pollInterval: 5000 + } + }; + const result = (await respondWith(body, CreateTaskResultSchema)) as { task: { taskId: string } }; + expect(result.task.taskId).toBe('786af6b0-2779-48ed-9cc1-b8a8a25b8a86'); + }); + it('an input_required-shaped body without content still fails loudly (wire-seam guard)', async () => { await expect(respondWith({ inputRequests: { r1: { method: 'elicitation/create' } } })).rejects.toBeInstanceOf(SdkError); await expect(respondWith({ requestState: 'opaque-token' })).rejects.toBeInstanceOf(SdkError); diff --git a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts index 76b35ba68c..e01885503f 100644 --- a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts +++ b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts @@ -140,10 +140,11 @@ describe('raw-first resultType handling — 2025 era (strip-on-lift, Q1-SD3 ii)' // BEHAVIOR MIGRATION (ledgered): pre-split, the era-blind funnel arm // rejected this with UnsupportedResultType on every leg. On the 2025 // era resultType carries no meaning — the ruled posture strips the - // foreign key. With the content default restored (v1 parity), the - // schema alone would accept the stripped husk, so the strip arm - // itself rejects a foreign body with no explicit content — the V-1 - // invariant (never a hollow success) holds by explicit guard. + // foreign key and validation judges the husk. With the content + // default restored (v1 parity), the wire-seam schema refuses to + // default a husk that carries input_required vocabulary + // (inputRequests/requestState) — the V-1 invariant (never a hollow + // success) holds at the seam. const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); const outcome = await settle(protocol); diff --git a/packages/core-internal/test/shared/typedMapAlignment.test.ts b/packages/core-internal/test/shared/typedMapAlignment.test.ts index 1c84d62249..9f4179bc63 100644 --- a/packages/core-internal/test/shared/typedMapAlignment.test.ts +++ b/packages/core-internal/test/shared/typedMapAlignment.test.ts @@ -93,10 +93,11 @@ describe('task-shaped result bodies against the narrowed runtime map', () => { await protocol.close(); }); - test('tools/call: a CreateTaskResult body is now a typed invalid-result error too (content-default removal flip)', async () => { - // FLIPPED PIN (Q1 increment 2, ledgered with the content-default - // removal — changeset: codec-split-wire-break). The previous "Honest - // pin, not an endorsement" recorded that CallToolResultSchema's + test('tools/call: a CreateTaskResult body on the plain path is a typed invalid-result error (wire-seam guard)', async () => { + // FLIPPED PIN, twice-ledgered: the codec split removed the content + // default (changeset: codec-split-wire-break); the restore brought it + // back with a wire-seam guard (changeset: + // calltoolresult-content-default). // A bare content.default([]) would swallow ANY object — including a // task body — as a content-empty success. The wire-seam schema (the // registry's tools/call entry) restores the v1 default for plain diff --git a/packages/core-internal/test/types/schemaBoundaryPins.test.ts b/packages/core-internal/test/types/schemaBoundaryPins.test.ts index 2ca6da0db2..72fa37fb72 100644 --- a/packages/core-internal/test/types/schemaBoundaryPins.test.ts +++ b/packages/core-internal/test/types/schemaBoundaryPins.test.ts @@ -145,9 +145,9 @@ describe('typed result schemas are loose', () => { // parsed as a silent success), then RESTORED for ecosystem parity — // real deployments omit `content` alongside `structuredContent`, and // v1 tolerated that. The T6 leak stays closed at its actual source: - // the 2025 codec's strip-on-lift rejects a stripped foreign body with - // no explicit content (rawResultTypeFirst.test.ts), and the 2026 era - // discriminates task bodies on `resultType` before parse. + // the 2025 wire-seam schema (registry) refuses to default content + // for another result family's vocabulary (rawResultTypeFirst / + // typedMapAlignment), and the 2026 era stays strict. const parsed = CallToolResultSchema.parse({ structuredContent: { ok: true } }); expect(parsed.content).toEqual([]); expect(parsed.structuredContent).toEqual({ ok: true }); diff --git a/packages/core-internal/test/types/specTypeSchema.test.ts b/packages/core-internal/test/types/specTypeSchema.test.ts index f7c2efff47..36affbd57d 100644 --- a/packages/core-internal/test/types/specTypeSchema.test.ts +++ b/packages/core-internal/test/types/specTypeSchema.test.ts @@ -95,7 +95,7 @@ describe('isSpecType', () => { // back for ecosystem parity (servers omit content alongside // structuredContent). The guard consequently accepts a content-less // body, exactly as v1's did; the task-husk leak the removal targeted - // is closed in the 2025 codec's strip arm instead. + // is closed at the 2025 wire-seam schema instead. const empty: unknown = {}; expect(isSpecType.CallToolResult(empty)).toBe(true); const v: unknown = { content: [] }; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index a1b272ecee..e5afebb956 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -38,6 +38,7 @@ import type { ToolUseContent } from '@modelcontextprotocol/core-internal'; import { + TOOL_RESULT_FOREIGN_FAMILY_KEYS, assertValidCacheHint, attachCacheHintFallback, CLIENT_CAPABILITIES_META_KEY, @@ -528,11 +529,15 @@ export class Server extends Protocol { // result without content (typically structuredContent-only from // dynamic/JS callers — the TypeScript surface requires content) // is normalized to content: [] before era validation, so the - // wire is spec-valid on every leg. Task-shaped results are not - // normalized — that vocabulary must not masquerade as a tool - // result. + // wire is spec-valid on every leg. Bodies carrying another + // result family's vocabulary (task / input_required) are not + // normalized — they must fail era validation loudly, never + // masquerade as an empty tool result. const normalizedResult = - result !== null && typeof result === 'object' && !('content' in result) && !('task' in result) + result !== null && + typeof result === 'object' && + !('content' in result) && + !TOOL_RESULT_FOREIGN_FAMILY_KEYS.some(key => key in result) ? { ...result, content: [] } : result; diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 5c1940e503..bb7b16261c 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -207,6 +207,16 @@ describe('Server', () => { expect(result!.structuredContent).toEqual({ ok: true }); }); + it('does not normalize a body carrying another result family — rejected loudly', async () => { + const response = await callToolOnServer({ + inputRequests: { r1: { method: 'elicitation/create' } } + } as unknown as CallToolResult); + + const error = (response as { error?: { code: number; message: string } }).error; + expect(error).toBeDefined(); + expect(error!.code).toBe(-32602); + }); + it('passes an authored-content result through to the wire', async () => { const response = await callToolOnServer({ content: [{ type: 'text', text: 'hi' }], From 3ae9e3dc48536b227cde6321318240dc6f79aa9c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 8 Jul 2026 16:35:54 +0000 Subject: [PATCH 5/6] docs: tighten comments --- packages/core-internal/src/types/schemas.ts | 5 ++--- packages/core-internal/src/wire/codec.ts | 6 ++---- .../src/wire/rev2025-11-25/codec.ts | 10 +++------- .../src/wire/rev2025-11-25/registry.ts | 9 +++------ .../src/wire/rev2025-11-25/schemas.ts | 5 ++--- .../test/shared/contentDefaultParity.test.ts | 9 +++------ .../test/shared/rawResultTypeFirst.test.ts | 11 +++-------- .../test/shared/typedMapAlignment.test.ts | 16 ++++------------ packages/core-internal/test/types.test.ts | 5 +---- .../test/types/schemaBoundaryPins.test.ts | 7 ++----- .../test/types/specTypeSchema.test.ts | 12 ++++-------- .../core-internal/test/wire/eraGates.test.ts | 15 +++++---------- packages/server/src/server/server.ts | 11 +++-------- packages/server/test/server/server.test.ts | 16 +++++----------- 14 files changed, 42 insertions(+), 95 deletions(-) diff --git a/packages/core-internal/src/types/schemas.ts b/packages/core-internal/src/types/schemas.ts index c7196cbf90..bc64597b98 100644 --- a/packages/core-internal/src/types/schemas.ts +++ b/packages/core-internal/src/types/schemas.ts @@ -1382,9 +1382,8 @@ export const CallToolResultSchema = ResultSchema.extend({ * If the `Tool` does not define an outputSchema, this field MUST be present in the result. * Required on the wire per the specification (it may be an empty array). * - * Parse-tolerant: an absent `content` defaults to `[]` (matching SDK v1), - * so results from servers that omit it — typically alongside - * `structuredContent` — don't fail the whole call. + * Parse-tolerant: absent `content` defaults to `[]` (v1 parity — deployed + * servers omit it alongside `structuredContent`). */ content: z.array(ContentBlockSchema).default([]), diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index 48f7911d6e..74ae8ab384 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -132,10 +132,8 @@ export interface OutboundEnvelopeMaterial { } /** - * Result-family vocabulary that must never ride a content-less body into a - * defaulted `{content: []}` tools/call success: task interop's `task`, and - * input_required's `inputRequests`/`requestState`. Shared by the 2025 - * wire-seam schema and the server's authoring normalization. + * Result-family keys that must never default into a `{content: []}` tools/call + * success. Shared by the 2025 wire-seam schema and server normalization. */ export const TOOL_RESULT_FOREIGN_FAMILY_KEYS = ['task', 'inputRequests', 'requestState'] as const; diff --git a/packages/core-internal/src/wire/rev2025-11-25/codec.ts b/packages/core-internal/src/wire/rev2025-11-25/codec.ts index 7225607ce4..de39f8b21a 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/codec.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/codec.ts @@ -105,13 +105,9 @@ export const rev2025Codec: WireCodec = { decodeResult(_method: string, raw: unknown): DecodedResult { // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is - // dropped before validation, whatever its value. There is no - // discrimination on this era — `resultType` carries no meaning here. - // Validation judges the husk: on the plain path the wire-seam schema - // (registry) refuses to default `content` for a body carrying another - // result family's vocabulary; explicit-schema callers own their own - // validation (task interop). A husk that was ONLY a resultType key - // defaults to an empty success — v1 parity for a payload-free body. + // dropped before validation, whatever its value. Validation judges the + // husk — the registry wire-seam schema on the plain path, the caller's + // schema on the explicit path (task interop). if (isPlainObject(raw) && 'resultType' in raw) { const stripped = { ...raw }; delete stripped['resultType']; diff --git a/packages/core-internal/src/wire/rev2025-11-25/registry.ts b/packages/core-internal/src/wire/rev2025-11-25/registry.ts index fc84b869d1..b9cd4a9eaf 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/registry.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/registry.ts @@ -108,12 +108,9 @@ type Rev2025TypedRequestMethod = Extract; // 2025-11-25 wire vocabulary with no SDK runtime; callers needing task // interop pass an explicit schema). /** - * Wire-seam guard for the tools/call content default: a content-less body - * carrying another 2025 result family's vocabulary (task interop's `task`, - * input_required's `inputRequests`/`requestState`) must fail loudly, never - * default into a hollow `{content: []}` success. The era is frozen, so this - * key list is complete by construction. Task interop via an EXPLICIT result - * schema is unaffected — that overload never consults this map. + * Wire-seam guard: a content-less body carrying another result family's keys + * fails loudly instead of defaulting to `{content: []}`. The era is frozen so + * the key list is complete; explicit-schema task interop never consults this. */ export const CallToolResultWireSchema = z .unknown() diff --git a/packages/core-internal/src/wire/rev2025-11-25/schemas.ts b/packages/core-internal/src/wire/rev2025-11-25/schemas.ts index 9c094bafce..72c4bef33a 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/schemas.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/schemas.ts @@ -1227,9 +1227,8 @@ export const CallToolResultSchema = ResultSchema.extend({ * If the `Tool` does not define an outputSchema, this field MUST be present in the result. * Required on the wire per the specification (it may be an empty array). * - * Parse-tolerant: an absent `content` defaults to `[]` (matching SDK v1), - * so results from servers that omit it — typically alongside - * `structuredContent` — don't fail the whole call. + * Parse-tolerant: absent `content` defaults to `[]` (v1 parity — deployed + * servers omit it alongside `structuredContent`). */ content: z.array(ContentBlockSchema).default([]), diff --git a/packages/core-internal/test/shared/contentDefaultParity.test.ts b/packages/core-internal/test/shared/contentDefaultParity.test.ts index 25458c923b..1f7e712abc 100644 --- a/packages/core-internal/test/shared/contentDefaultParity.test.ts +++ b/packages/core-internal/test/shared/contentDefaultParity.test.ts @@ -17,12 +17,9 @@ class TestProtocolImpl extends Protocol { } /** - * v1 parse-parity for `CallToolResult.content` on the legacy era: deployed - * servers omit `content` alongside `structuredContent`, and SDK v1 accepted - * that for years. The result resolves with `content: []` instead of failing - * the whole call — while another result family's vocabulary without content - * stays a loud error (the guard lives in the wire-seam schema, the - * registry's tools/call entry). + * v1 parse-parity for `CallToolResult.content` on the legacy era: absent + * content defaults to []; another result family's content-less body still + * fails loudly via the registry wire-seam schema. */ describe('CallToolResult content default (v1 parity)', () => { async function respondWith(body: Record, resultSchema?: Parameters['request']>[1]) { diff --git a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts index e01885503f..87519c1562 100644 --- a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts +++ b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts @@ -137,14 +137,9 @@ describe('raw-first resultType discrimination — 2026 era (codec decode step 1) describe('raw-first resultType handling — 2025 era (strip-on-lift, Q1-SD3 ii)', () => { test('a foreign input_required body is stripped, then validation judges the content — never a silent success', async () => { - // BEHAVIOR MIGRATION (ledgered): pre-split, the era-blind funnel arm - // rejected this with UnsupportedResultType on every leg. On the 2025 - // era resultType carries no meaning — the ruled posture strips the - // foreign key and validation judges the husk. With the content - // default restored (v1 parity), the wire-seam schema refuses to - // default a husk that carries input_required vocabulary - // (inputRequests/requestState) — the V-1 invariant (never a hollow - // success) holds at the seam. + // BEHAVIOR MIGRATION (ledgered): the strip drops the foreign key and + // the wire-seam schema refuses to default a husk carrying + // input_required keys — V-1 (never a hollow success) holds at the seam. const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); const outcome = await settle(protocol); diff --git a/packages/core-internal/test/shared/typedMapAlignment.test.ts b/packages/core-internal/test/shared/typedMapAlignment.test.ts index 9f4179bc63..a8ad903651 100644 --- a/packages/core-internal/test/shared/typedMapAlignment.test.ts +++ b/packages/core-internal/test/shared/typedMapAlignment.test.ts @@ -94,18 +94,10 @@ describe('task-shaped result bodies against the narrowed runtime map', () => { }); test('tools/call: a CreateTaskResult body on the plain path is a typed invalid-result error (wire-seam guard)', async () => { - // FLIPPED PIN, twice-ledgered: the codec split removed the content - // default (changeset: codec-split-wire-break); the restore brought it - // back with a wire-seam guard (changeset: - // calltoolresult-content-default). - // A bare content.default([]) would swallow ANY object — including a - // task body — as a content-empty success. The wire-seam schema (the - // registry's tools/call entry) restores the v1 default for plain - // results but refuses to default a body carrying another result - // family's vocabulary: a task body has no `content`, fails the - // wire-seam schema, and surfaces as the same typed invalid-result - // error as sampling/elicit. Loud rejection, tolerant defaults — - // both at once. + // FLIPPED PIN, twice-ledgered (changesets: codec-split-wire-break, + // calltoolresult-content-default). The wire-seam schema restores the + // v1 default for plain results but refuses to default a body carrying + // another result family's keys — a task body fails it loudly. const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); const rejection = await protocol diff --git a/packages/core-internal/test/types.test.ts b/packages/core-internal/test/types.test.ts index b085f9e35a..e836086bfc 100644 --- a/packages/core-internal/test/types.test.ts +++ b/packages/core-internal/test/types.test.ts @@ -298,10 +298,7 @@ describe('Types', () => { test('tolerates absent content: the empty-object result parses with content [] (v1 parity restored)', () => { // BEHAVIOR MIGRATION (reversal, ledgered): content.default([]) is - // back on the neutral layer and the 2025 era — deployed servers - // omit content alongside structuredContent. The T6 masking root - // is closed by vocabulary guards in the 2025 codec and by the - // strict 2026-era schemas instead. + // back on the neutral layer + 2025 era; T6 closed at the wire seam. const empty = CallToolResultSchema.safeParse({}); expect(empty.success).toBe(true); if (empty.success) { diff --git a/packages/core-internal/test/types/schemaBoundaryPins.test.ts b/packages/core-internal/test/types/schemaBoundaryPins.test.ts index 72fa37fb72..26c06ac11d 100644 --- a/packages/core-internal/test/types/schemaBoundaryPins.test.ts +++ b/packages/core-internal/test/types/schemaBoundaryPins.test.ts @@ -143,11 +143,8 @@ describe('typed result schemas are loose', () => { // BEHAVIOR MIGRATION (reversal, ledgered): `content.default([])` was // removed in the codec split (T6 width-leak root: a task-shaped body // parsed as a silent success), then RESTORED for ecosystem parity — - // real deployments omit `content` alongside `structuredContent`, and - // v1 tolerated that. The T6 leak stays closed at its actual source: - // the 2025 wire-seam schema (registry) refuses to default content - // for another result family's vocabulary (rawResultTypeFirst / - // typedMapAlignment), and the 2026 era stays strict. + // real deployments omit `content` alongside `structuredContent`. The + // T6 leak stays closed at the 2025 wire-seam schema; 2026 stays strict. const parsed = CallToolResultSchema.parse({ structuredContent: { ok: true } }); expect(parsed.content).toEqual([]); expect(parsed.structuredContent).toEqual({ ok: true }); diff --git a/packages/core-internal/test/types/specTypeSchema.test.ts b/packages/core-internal/test/types/specTypeSchema.test.ts index 36affbd57d..d6c775b4e3 100644 --- a/packages/core-internal/test/types/specTypeSchema.test.ts +++ b/packages/core-internal/test/types/specTypeSchema.test.ts @@ -91,19 +91,15 @@ describe('isSpecType', () => { }); it('CallToolResult tolerates absent content at the boundary (default restored, v1 parity)', () => { - // BEHAVIOR MIGRATION (reversal, ledgered): content.default([]) is - // back for ecosystem parity (servers omit content alongside - // structuredContent). The guard consequently accepts a content-less - // body, exactly as v1's did; the task-husk leak the removal targeted - // is closed at the 2025 wire-seam schema instead. + // BEHAVIOR MIGRATION (reversal, ledgered): the guard accepts a + // content-less body as v1's did; the task-husk leak is closed at the + // 2025 wire-seam schema instead. const empty: unknown = {}; expect(isSpecType.CallToolResult(empty)).toBe(true); const v: unknown = { content: [] }; expect(isSpecType.CallToolResult(v)).toBe(true); if (isSpecType.CallToolResult(v)) { - // The guard narrows to the INPUT type (before defaults), so - // content is honestly optional here — the default only - // materializes on parse. + // The guard narrows to the INPUT type: content optional pre-parse. expectTypeOf(v.content).toEqualTypeOf(); } // The parsed/public type keeps content required (z.output). diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index fbf112cc40..b03202e011 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -577,17 +577,12 @@ describe('T6 width-leak killed at both roots', () => { test('2025 era: a bare task-shaped body fails the wire-seam schema — the content default cannot mask it', async () => { const { rev2025Codec } = await import('../../src/wire/rev2025-11-25/codec'); const decoded = rev2025Codec.decodeResult('tools/call', { task: { taskId: 't-1', status: 'working' } }); - // Decode is a pass-through for bodies without resultType (task is - // this era's own vocabulary — explicit-schema interop must work); - // the WIRE-SEAM schema (registry entry) refuses to default content - // for a body carrying another result family's keys. + // Decode passes bodies without resultType through (explicit-schema + // task interop must work); the wire-seam schema does the refusing. expect(decoded.kind).toBe('complete'); - // The registry's wire-seam schema (the plain per-method entry) then - // rejects the body: content is never defaulted for another result - // family's vocabulary — even a fully conforming CreateTaskResult - // (surfaced as a typed INVALID_RESULT — see - // test/shared/typedMapAlignment.test.ts). Task interop is the - // explicit-schema overload, never a silent union member. + // The wire-seam schema rejects even a fully conforming + // CreateTaskResult on the plain path (typed INVALID_RESULT); task + // interop is the explicit-schema overload only. const { getResultSchema } = await import('../../src/wire/rev2025-11-25/registry'); const wireSeam = getResultSchema('tools/call'); expect( diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index e5afebb956..37d9aae2d6 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -525,14 +525,9 @@ export class Server extends Protocol { return result; } - // v1-parity authoring affordance, era-independent: a handler - // result without content (typically structuredContent-only from - // dynamic/JS callers — the TypeScript surface requires content) - // is normalized to content: [] before era validation, so the - // wire is spec-valid on every leg. Bodies carrying another - // result family's vocabulary (task / input_required) are not - // normalized — they must fail era validation loudly, never - // masquerade as an empty tool result. + // v1-parity authoring affordance, era-independent: a content-less + // handler result normalizes to content: [] before era validation. + // Other result families' bodies stay un-normalized and fail loudly. const normalizedResult = result !== null && typeof result === 'object' && diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index bb7b16261c..c09101d89e 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -155,13 +155,9 @@ describe('Server', () => { }); describe('tools/call handler-result validation (content default)', () => { - // Server-side pin for the v1-parity authoring affordance - // (docs/migration/upgrade-to-v2.md, "Wire tightening (every era)"): - // a handler result without `content` is normalized to `content: []` - // BEFORE era validation — era-independently, so the wire is - // spec-valid on every leg — while an authored-content result passes - // through the wrapped handler untouched. Task-shaped handler results - // are not normalized. + // Pin for the v1-parity authoring affordance: content-less handler + // results normalize to content: [] before era validation, on every + // leg; other result families are not normalized. async function callToolOnServer(result: CallToolResult): Promise { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/call', () => result); @@ -195,10 +191,8 @@ describe('Server', () => { } it('defaults a structured-only handler result (no content) to content: [] on the wire (v1 parity)', async () => { - // The TypeScript surface requires `content`; at runtime a - // structured-only result (dynamic/JS callers) is defaulted rather - // than rejected, matching v1 — and the wire output is - // spec-valid: content is present as []. + // Runtime defaults structured-only results (v1 parity); the wire + // stays spec-valid with content: []. const response = await callToolOnServer({ structuredContent: { ok: true } } as unknown as CallToolResult); const result = (response as { result?: { content?: unknown; structuredContent?: unknown } }).result; From dccc400c6c619c3872e8d7998cebb9aec7ba4369 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 8 Jul 2026 16:46:44 +0000 Subject: [PATCH 6/6] fix: harden the content-default seams per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - changeset: name client and core — the published packages whose bundled parse behavior this changes; core-internal alone releases nothing. - registry: TOOL_RESULT_FOREIGN_FAMILY_KEYS moves to a leaf module (resultFamilies.ts) — importing it from wire/codec closed the runtime cycle the wire layer's own layering note forbids. - wire-seam guard + server normalization: treat an explicit content: undefined like an absent key (authoring objects can carry it), and never normalize arrays — both previously slipped through as silent empty successes; tests added. - wire sniffer: with the neutral union tolerant again, assert legacy-cell era-gating by result-family vocabulary explicitly, and say why. - guide: a resultType-only husk defaults (v1 parity) — stop listing it among the still-rejected shapes. --- .changeset/calltoolresult-content-default.md | 2 ++ docs/migration/upgrade-to-v2.md | 7 ++++--- packages/core-internal/src/index.ts | 3 ++- packages/core-internal/src/wire/codec.ts | 6 ------ .../core-internal/src/wire/resultFamilies.ts | 7 +++++++ .../src/wire/rev2025-11-25/registry.ts | 7 +++++-- .../test/shared/contentDefaultParity.test.ts | 6 ++++++ packages/server/src/server/server.ts | 3 ++- packages/server/test/server/server.test.ts | 17 +++++++++++++++++ test/e2e/helpers/wire-sniffer.ts | 18 +++++++++++++++--- 10 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 packages/core-internal/src/wire/resultFamilies.ts diff --git a/.changeset/calltoolresult-content-default.md b/.changeset/calltoolresult-content-default.md index ef8b3e43ac..c6b5530a13 100644 --- a/.changeset/calltoolresult-content-default.md +++ b/.changeset/calltoolresult-content-default.md @@ -1,5 +1,7 @@ --- '@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/core': patch '@modelcontextprotocol/server': patch --- diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 8f72b9050f..a5d5e99a5f 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1679,9 +1679,10 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see unchanged and era-independent: the TypeScript surface requires `content` on handler results, and a content-less handler result is normalized to `content: []` before it reaches the wire. One sharpening remains: a content-less body carrying another - result family's vocabulary (a task handle, an `input_required` round, or a stripped - foreign `resultType`) is still rejected loudly — tolerance never turns a different - result kind into a silent empty success. + result family's vocabulary (a task handle or an `input_required` round) is still + rejected loudly — tolerance never turns a different result kind into a silent empty + success. A body whose only foreign key was `resultType` strips to an empty object + and defaults, exactly as v1 parsed a payload-free body. - **`ElicitResult.content` values are typed and validated as `string | number | boolean | string[]`.** v1's TypeScript surface accepted `Record` content values; an elicitation handler returning arbitrary diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 7534505b81..46a3198d5d 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -32,7 +32,8 @@ export * from './util/inMemory'; export * from './util/schema'; export * from './util/standardSchema'; export * from './util/zodCompat'; -export { codecForVersion, MODERN_WIRE_REVISION, TOOL_RESULT_FOREIGN_FAMILY_KEYS } from './wire/codec'; +export { codecForVersion, MODERN_WIRE_REVISION } from './wire/codec'; +export { TOOL_RESULT_FOREIGN_FAMILY_KEYS } from './wire/resultFamilies'; // Validator provider classes stay subpath-only. Re-exporting them here, even as // `type`, can make generated client/server root declarations advertise diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index 74ae8ab384..9efb9a12f3 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -131,12 +131,6 @@ export interface OutboundEnvelopeMaterial { readonly logLevel?: LoggingLevel; } -/** - * Result-family keys that must never default into a `{content: []}` tools/call - * success. Shared by the 2025 wire-seam schema and server normalization. - */ -export const TOOL_RESULT_FOREIGN_FAMILY_KEYS = ['task', 'inputRequests', 'requestState'] as const; - /** Result decode outcomes — the raw-first discrimination (V-1) lives in `decodeResult`. */ export type DecodedResult = | { diff --git a/packages/core-internal/src/wire/resultFamilies.ts b/packages/core-internal/src/wire/resultFamilies.ts new file mode 100644 index 0000000000..e03d66a4d0 --- /dev/null +++ b/packages/core-internal/src/wire/resultFamilies.ts @@ -0,0 +1,7 @@ +/** + * Result-family keys that must never default into a `{content: []}` tools/call + * success. Shared by the 2025 wire-seam schema and server normalization. + * Leaf module (like `textFallback.ts`): imported by registry/server paths, so + * it must NOT import from `./codec.js` — that would close a runtime cycle. + */ +export const TOOL_RESULT_FOREIGN_FAMILY_KEYS = ['task', 'inputRequests', 'requestState'] as const; diff --git a/packages/core-internal/src/wire/rev2025-11-25/registry.ts b/packages/core-internal/src/wire/rev2025-11-25/registry.ts index b9cd4a9eaf..29143eddb3 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/registry.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/registry.ts @@ -24,7 +24,7 @@ import * as z from 'zod/v4'; import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types'; -import { TOOL_RESULT_FOREIGN_FAMILY_KEYS } from '../codec'; +import { TOOL_RESULT_FOREIGN_FAMILY_KEYS } from '../resultFamilies'; import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas'; import { CallToolRequestSchema, @@ -115,7 +115,10 @@ type Rev2025TypedRequestMethod = Extract; export const CallToolResultWireSchema = z .unknown() .superRefine((value, ctx) => { - if (typeof value !== 'object' || value === null || Array.isArray(value) || 'content' in value) return; + // content === undefined covers both an absent key and an explicit + // undefined from server-side authoring objects. + if (typeof value !== 'object' || value === null || Array.isArray(value) || (value as Record).content !== undefined) + return; for (const key of TOOL_RESULT_FOREIGN_FAMILY_KEYS) { if (key in value) { ctx.addIssue({ diff --git a/packages/core-internal/test/shared/contentDefaultParity.test.ts b/packages/core-internal/test/shared/contentDefaultParity.test.ts index 1f7e712abc..2f60fb7c64 100644 --- a/packages/core-internal/test/shared/contentDefaultParity.test.ts +++ b/packages/core-internal/test/shared/contentDefaultParity.test.ts @@ -96,6 +96,12 @@ describe('CallToolResult content default (v1 parity)', () => { expect(result.task.taskId).toBe('786af6b0-2779-48ed-9cc1-b8a8a25b8a86'); }); + it('the wire-seam guard treats an explicit content: undefined like an absent key', async () => { + const { getResultSchema } = await import('../../src/wire/rev2025-11-25/registry'); + const wireSeam = getResultSchema('tools/call')!; + expect(wireSeam.safeParse({ task: { taskId: 't-1', status: 'working' }, content: undefined }).success).toBe(false); + }); + it('an input_required-shaped body without content still fails loudly (wire-seam guard)', async () => { await expect(respondWith({ inputRequests: { r1: { method: 'elicitation/create' } } })).rejects.toBeInstanceOf(SdkError); await expect(respondWith({ requestState: 'opaque-token' })).rejects.toBeInstanceOf(SdkError); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 37d9aae2d6..fff14af79c 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -531,7 +531,8 @@ export class Server extends Protocol { const normalizedResult = result !== null && typeof result === 'object' && - !('content' in result) && + !Array.isArray(result) && + (result as { content?: unknown }).content === undefined && !TOOL_RESULT_FOREIGN_FAMILY_KEYS.some(key => key in result) ? { ...result, content: [] } : result; diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index c09101d89e..0a96a0bb66 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -201,6 +201,23 @@ describe('Server', () => { expect(result!.structuredContent).toEqual({ ok: true }); }); + it('does not normalize an array handler result — rejected loudly', async () => { + const response = await callToolOnServer([{ type: 'text', text: 'hi' }] as unknown as CallToolResult); + const error = (response as { error?: { code: number } }).error; + expect(error).toBeDefined(); + expect(error!.code).toBe(-32602); + }); + + it('does not normalize a foreign-family body with an explicit content: undefined', async () => { + const response = await callToolOnServer({ + task: { taskId: 't-1', status: 'working' }, + content: undefined + } as unknown as CallToolResult); + const error = (response as { error?: { code: number } }).error; + expect(error).toBeDefined(); + expect(error!.code).toBe(-32602); + }); + it('does not normalize a body carrying another result family — rejected loudly', async () => { const response = await callToolOnServer({ inputRequests: { r1: { method: 'elicitation/create' } } diff --git a/test/e2e/helpers/wire-sniffer.ts b/test/e2e/helpers/wire-sniffer.ts index 44644dcd05..490f46aa7c 100644 --- a/test/e2e/helpers/wire-sniffer.ts +++ b/test/e2e/helpers/wire-sniffer.ts @@ -16,6 +16,10 @@ import { isSpecType } from '@modelcontextprotocol/server'; +function isPlainRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + export type WireParty = 'client' | 'server'; export interface SnifferOptions { @@ -102,11 +106,19 @@ export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferO // input_required on a 2025-era cell's wire is still flagged. if (party === 'server' && isInputRequiredResult(result)) { if (opts.allowInputRequiredResults === true) return; - // Explicit flag: the neutral union no longer rejects this shape - // by strictness (CallToolResult.content defaults on the legacy - // era), so the era-gating is asserted here, by vocabulary. 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. Era-gating for the other result families is + // asserted here explicitly, by vocabulary. + if (party === 'server' && isPlainRecord(result) && (result as Record).content === undefined) { + for (const key of ['task', 'inputRequests', 'requestState']) { + if (key in result) { + fail(party, `content-less result carrying '${key}' on a legacy cell`, msg); + } + } + } const r = schemas.result.safeParse(result); if (!r.success) { // A result for a vendor-extension request legitimately won't match the spec union.