Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/calltoolresult-content-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/server': patch
---
Comment thread
felixweinberger marked this conversation as resolved.

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), 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.
8 changes: 5 additions & 3 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
14 changes: 10 additions & 4 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1673,10 +1673,16 @@ 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 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 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<string, unknown>` content values; an elicitation handler returning arbitrary
Expand Down
1 change: 1 addition & 0 deletions packages/core-internal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export * from './util/schema';
export * from './util/standardSchema';
export * from './util/zodCompat';
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
Expand Down
5 changes: 4 additions & 1 deletion packages/core-internal/src/types/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1381,8 +1381,11 @@ 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: absent `content` defaults to `[]` (v1 parity — deployed
* servers omit it alongside `structuredContent`).
*/
content: z.array(ContentBlockSchema),
content: z.array(ContentBlockSchema).default([]),
Comment on lines +1384 to +1388

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The restored content: z.array(ContentBlockSchema).default([]) makes the public isCallToolResult guard (packages/core-internal/src/types/guards.ts:86-89) unsound for an in-process value with an explicit content: undefined key: the 'content' in value pre-check passes, Zod's .default() fires on the undefined value, and the guard narrows the original value to CallToolResult with content required — so v.content.map(...) then throws a TypeError, where pre-PR the guard soundly returned false. Treat content === undefined like an absent key in the guard, or narrow to the input type as isSpecType.CallToolResult was deliberately re-pinned to do in this PR.

Extended reasoning...

What the bug is

The restored content: z.array(ContentBlockSchema).default([]) on the neutral CallToolResultSchema (packages/core-internal/src/types/schemas.ts:1388) silently changes the behavior of the public type guard isCallToolResult (packages/core-internal/src/types/guards.ts:86-89 — not in the diff, but directly downstream of this changed schema). The guard is declared as value is CallToolResult (the z.output type, where content: ContentBlock[] is REQUIRED) and implemented as: reject when !('content' in value), otherwise return CallToolResultSchema.safeParse(value).success.

The code path that triggers it

Zod applies a .default() whenever the field's input value is undefined — including an own key explicitly set to undefined (verified empirically with zod 4.4.x: a loose object with content: z.array(...).default([]) successfully parses {content: undefined, structuredContent: {...}}, while the pre-PR default-free shape rejected it with invalid_type). So for v = { content: undefined, structuredContent: {...} }:

  1. 'content' in v is true (the in operator sees an own key regardless of its value), so the guard's presence pre-check does NOT reject;
  2. safeParse succeeds via the restored default;
  3. the guard returns true, and TypeScript narrows the original value (not the parsed copy) to CallToolResult with content: ContentBlock[] required — while v.content is actually undefined at runtime.

Why existing code doesn't prevent it

Pre-PR, safeParse({content: undefined}) failed (required array), so the guard returned false and the narrowing was sound — this is a soundness regression introduced by the schema line this PR edits. The existing pin (guards.test.ts:81) covers only the absent-key case, which still returns false. Notably, the sibling isSpecType.CallToolResult guard is safe because this PR deliberately re-pinned it to narrow to the z.input type (content optional) — the flipped assertion in test/types/specTypeSchema.test.ts documents exactly that input-vs-output distinction; isCallToolResult received no equivalent treatment.

Impact

isCallToolResult is public API (exported via core-internal/exports/public/index.ts, re-exported by both @modelcontextprotocol/client and @modelcontextprotocol/server), and its own JSDoc scopes it as a consumer-side VALUE check against the neutral model — in-process values are exactly its population. Consumer code that trusts the narrowing and reads v.content.map(...) or v.content.length crashes with a TypeError. JSON wire traffic can never carry undefined, so only in-process values (spreads of optional props, hand-built objects, proxied bodies) trigger it — which is why this is a nit rather than a blocker.

Step-by-step proof

  1. const v: unknown = { content: undefined, structuredContent: { ok: true } }; (e.g. a handler forwarding { content: downstream.content, structuredContent: downstream.structuredContent } where downstream.content is absent).
  2. isCallToolResult(v): the 'content' in value check passes (own key exists); CallToolResultSchema.safeParse(v) succeeds post-PR because .default([]) fires on the undefined value → guard returns true. (Pre-PR: safeParse fails with invalid_type: expected array, received undefined → guard returns false.)
  3. Inside the narrowed branch, TypeScript types v.content as ContentBlock[] — but the runtime value is still undefined (the guard never replaces the original object with the parsed copy).
  4. v.content.map(b => b.type)TypeError: Cannot read properties of undefined (reading 'map').

How to fix

One-line change in isCallToolResult: treat content === undefined the same as an absent key (e.g. (value as {content?: unknown}).content === undefined instead of / in addition to !('content' in value)), so the guard keeps returning false for this shape. Alternatively, narrow the predicate to the z.input type (content optional) exactly as isSpecType.CallToolResult now does — either restores soundness.

This is the same presence-vs-undefined blind spot already flagged for the server normalization / wire-seam superRefine in an earlier inline comment, but at a different site (a public type guard) with a different consequence (unsound TypeScript narrowing in consumer code rather than a hollow wire success).


/**
* Structured tool output.
Expand Down
7 changes: 7 additions & 0 deletions packages/core-internal/src/wire/resultFamilies.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions packages/core-internal/src/wire/rev2025-11-25/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +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.
// 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'];
Expand Down
29 changes: 27 additions & 2 deletions packages/core-internal/src/wire/rev2025-11-25/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
* 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 { TOOL_RESULT_FOREIGN_FAMILY_KEYS } from '../resultFamilies';
import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas';
import {
CallToolRequestSchema,
Expand Down Expand Up @@ -106,6 +107,30 @@ type Rev2025TypedRequestMethod = Extract<RequestMethod, Rev2025RequestMethod>;
// 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: 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()
.superRefine((value, ctx) => {
// 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<string, unknown>).content !== undefined)
return;
for (const key of TOOL_RESULT_FOREIGN_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<ResultTypeMap[M]> } = {
ping: EmptyResultSchema,
initialize: InitializeResultSchema,
Expand All @@ -118,7 +143,7 @@ const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType<Resu
'resources/read': ReadResourceResultSchema,
'resources/subscribe': EmptyResultSchema,
'resources/unsubscribe': EmptyResultSchema,
'tools/call': CallToolResultSchema,
'tools/call': CallToolResultWireSchema,
'tools/list': ListToolsResultSchema,
'sampling/createMessage': CreateMessageResultWithToolsSchema,
'elicitation/create': ElicitResultSchema,
Expand Down
5 changes: 4 additions & 1 deletion packages/core-internal/src/wire/rev2025-11-25/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1226,8 +1226,11 @@ 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: absent `content` defaults to `[]` (v1 parity — deployed
* servers omit it alongside `structuredContent`).
*/
content: z.array(ContentBlockSchema),
content: z.array(ContentBlockSchema).default([]),

/**
* An object containing structured tool output.
Expand Down
3 changes: 3 additions & 0 deletions packages/core-internal/src/wire/rev2026-07-28/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -1070,6 +1072,7 @@ function liftedResult<T extends z.core.$ZodLooseShape>(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()
Expand Down
109 changes: 109 additions & 0 deletions packages/core-internal/test/shared/contentDefaultParity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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<BaseContext> {
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: 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<string, unknown>, resultSchema?: Parameters<Protocol<BaseContext>['request']>[1]) {
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 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(() => {});
}
}

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 (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('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('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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,10 @@
});

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 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.
// 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.

Check warning on line 142 in packages/core-internal/test/shared/rawResultTypeFirst.test.ts

View check run for this annotation

Claude / Claude Code Review

Two off-diff stale ledger comments still describe the removed content default

Two off-diff twins of the ledger comments this PR rewrote still describe the removed content requirement: test/integration/test/client/client.test.ts:1831-1832 says the wire default([]) was removed (it is now restored, and the cited codec-split-wire-break changeset is superseded by calltoolresult-content-default), and test/e2e/scenarios/raw-result-type.test.ts (header ~lines 19-20 and inline comment ~lines 118-120) still attributes the legacy-arm rejection to the '(default-free) result schema' w
Comment on lines 139 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Two off-diff twins of the ledger comments this PR rewrote still describe the removed content requirement: test/integration/test/client/client.test.ts:1831-1832 says the wire default([]) was removed (it is now restored, and the cited codec-split-wire-break changeset is superseded by calltoolresult-content-default), and test/e2e/scenarios/raw-result-type.test.ts (header ~lines 19-20 and inline comment ~lines 118-120) still attributes the legacy-arm rejection to the '(default-free) result schema' when the rejection now comes from the CallToolResultWireSchema family-vocabulary guard. Comment-only — all assertions still pass; reword both to match the rewritten comment here (wire-seam guard, restored default, calltoolresult-content-default changeset).

Extended reasoning...

What the issue is

This PR rewrote the ledger comment at packages/core-internal/test/shared/rawResultTypeFirst.test.ts:139-142 (and its siblings in types.test.ts, schemaBoundaryPins.test.ts, specTypeSchema.test.ts, typedMapAlignment.test.ts, eraGates.test.ts, and server.test.ts) to describe the new mechanism: the content default is restored and the wire-seam schema refuses to default a husk carrying another result family's keys. Two off-diff twins of the same prose were missed and now describe a mechanism the codebase no longer ships:

  1. test/integration/test/client/client.test.ts:1831-1832 — next to a tools/call handler returning { content: [], structuredContent: … }, the comment says content is spec-required (the wire default([]) was removed - ledgered; changeset codec-split-wire-break). After this PR that is false on both counts: content: z.array(ContentBlockSchema).default([]) is restored on the neutral CallToolResultSchema (types/schemas.ts:1388) and the 2025-era wire schema, and the server authoring seam normalizes an absent content to [] — so content is no longer spec-required at the parse boundary on the legacy era, and the cited changeset rationale is superseded by calltoolresult-content-default.

  2. test/e2e/scenarios/raw-result-type.test.ts — the file header (lines 19-20) says The stripped body then fails the (default-free) result schema loudly because it has no content, and the inline comment in the legacy arm (lines 118-120) repeats the body has no content, so validation fails LOUDLY. Both attribute the rejection to a schema that is no longer default-free.

Why the e2e attribution is now wrong (step-by-step)

  1. The e2e test's server answers with the INPUT_REQUIRED_BODY husk: { resultType: 'input_required', inputRequests: {…}, requestState: '…' } on a legacy-negotiated connection.
  2. rev2025Codec.decodeResult strips the foreign resultType and passes the husk through (the codec-level content guard was deleted in e4dc30c — the strip is a pass-through again).
  3. The husk reaches CallToolResultWireSchema (rev2025-11-25/registry.ts), whose superRefine sees content === undefined and a TOOL_RESULT_FOREIGN_FAMILY_KEYS member (inputRequests/requestState) → adds an issue → InvalidResult, exactly what the test asserts.
  4. But if the husk carried no family-vocabulary key, the superRefine would pass it through to the piped CallToolResultSchema, where content now defaults to [] — the parse succeeds. So "fails loudly because it has no content" is the opposite of the shipped behavior: content-lessness alone no longer rejects; the family vocabulary does.

Why nothing catches it

Both files are outside the diff, and the prose is comment-only — no assertion depends on it, so every suite stays green (the e2e fixture happens to carry family vocabulary, so InvalidResult still fires for the reason the guard names, not the reason the comment names).

Impact

A future reader tracing either comment lands on a rationale (required content on the plain schema; changeset codec-split-wire-break) the codebase no longer ships — the same partial-comment-migration class this PR's review rounds already fixed in server.test.ts, typedMapAlignment.test.ts, and this very file's unit twin. The e2e header actively misleads: it implies any content-less body is rejected, when a content-less body without family keys now succeeds with content: [].

How to fix

  • In client.test.ts, reword to e.g. explicit content authored here; the legacy-era wire default was restored — changeset: calltoolresult-content-default (or drop the parenthetical).
  • In raw-result-type.test.ts, update the header bullet and inline comment to name the wire-seam mechanism, mirroring the rewritten comment here: the strip drops the foreign key and CallToolResultWireSchema refuses to default a husk carrying input_required family keys (inputRequests/requestState); a payload-free husk without those keys defaults to content: [].

Comment-only, no runtime or CI impact — a one-line reword at each site, not a merge blocker.

const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY);
const outcome = await settle(protocol);

Expand Down
17 changes: 5 additions & 12 deletions packages/core-internal/test/shared/typedMapAlignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +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
// 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.
test('tools/call: a CreateTaskResult body on the plain path is a typed invalid-result error (wire-seam guard)', async () => {
// 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
Expand Down
14 changes: 8 additions & 6 deletions packages/core-internal/test/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,14 @@ 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 + 2025 era; T6 closed at the wire seam.
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) {
Expand Down
Loading
Loading