Skip to content

Commit 44797d7

Browse files
fix(core): restore the v1 content default for tools/call results on the legacy era (#2456)
1 parent 3834921 commit 44797d7

24 files changed

Lines changed: 347 additions & 96 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@modelcontextprotocol/core-internal': patch
3+
'@modelcontextprotocol/client': patch
4+
'@modelcontextprotocol/core': patch
5+
'@modelcontextprotocol/server': patch
6+
---
7+
8+
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.
9+
10+
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.
11+
12+
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.
13+
14+
Conscious call: the nested sampling `ToolResultContentSchema` stays spec-strict — v1 had defaulted its `content` too, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.

docs/migration/support-2026-07-28.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,9 +340,11 @@ If you were on a v2 alpha and consumed wire schemas directly:
340340

341341
The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x
342342
impact** — these members did not exist before 2026-07-28. The neutral-model wire
343-
tightening that **does** affect v1 code (`content` required, custom-handler `_meta`
344-
passthrough, `specTypeSchemas` narrowing) is in
345-
[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era).
343+
tightening that **does** affect v1 code (custom-handler `_meta` passthrough,
344+
`specTypeSchemas` narrowing) is in
345+
[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era);
346+
`CallToolResult.content` keeps its v1 default on the legacy era (2026-07-28
347+
connections require it explicitly).
346348

347349
> **If you were on a v2 alpha:** the 2026-07-28 draft error codes were renumbered:
348350
> `HeaderMismatch` `-32001``-32020`, `MissingRequiredClientCapability` `-32003``-32021`,

docs/migration/upgrade-to-v2.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,10 +1673,16 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see
16731673
16741674
#### Wire tightening (every era)
16751675
1676-
- **`CallToolResult.content` is required at the wire boundary.** The `content.default([])`
1677-
affordance was removed. Tool handlers MUST include `content` (the TypeScript surface
1678-
always required it; `content: []` is fine). A handler result without it is rejected
1679-
with `-32602`.
1676+
- **`CallToolResult.content` keeps the v1 parse tolerance on the legacy era.** An
1677+
inbound result without `content` defaults to `[]` (deployed servers omit it
1678+
alongside `structuredContent`); 2026-07-28 connections stay strict. Authoring is
1679+
unchanged and era-independent: the TypeScript surface requires `content` on handler
1680+
results, and a content-less handler result is normalized to `content: []` before it
1681+
reaches the wire. One sharpening remains: a content-less body carrying another
1682+
result family's vocabulary (a task handle or an `input_required` round) is still
1683+
rejected loudly — tolerance never turns a different result kind into a silent empty
1684+
success. A body whose only foreign key was `resultType` strips to an empty object
1685+
and defaults, exactly as v1 parsed a payload-free body.
16801686
- **`ElicitResult.content` values are typed and validated as
16811687
`string | number | boolean | string[]`.** v1's TypeScript surface accepted
16821688
`Record<string, unknown>` content values; an elicitation handler returning arbitrary

packages/core-internal/eslint.config.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ export default [
77
{
88
// Wire-layer isolation, outbound direction: nothing outside src/wire/ may
99
// reach into a wire revision module. The wire layer's only public surface
10-
// is src/wire/codec.ts (the WireCodec interface) and src/wire/bootstrap.ts.
10+
// is src/wire/codec.ts (the WireCodec interface), src/wire/bootstrap.ts,
11+
// and the leaf result-family module src/wire/resultFamilies.ts (the shared
12+
// tools/call-result ruling, re-exported on the barrel).
1113
// test/wire/layeringInvariants.test.ts re-derives the same invariant with
1214
// zero exceptions. Type-only imports are exempted at the lint layer (a
1315
// type-only crossing is erased at runtime), but the test allows none.

packages/core-internal/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,15 @@ export * from './util/inMemory';
2828
// 2026-only seam runs in. NOTHING per-revision (registries, codec objects,
2929
// per-revision schemas) is ever exported on this barrel — sibling packages
3030
// reach the wire layer ONLY through `codecForVersion`'s function-only
31-
// `WireCodec` surface.
31+
// `WireCodec` surface. Sole exemption: the shared result-family ruling
32+
// (`wire/resultFamilies.ts`), era-independent by design — the server's
33+
// authoring normalization and the e2e wire sniffer apply the same ruling
34+
// (and name the same vocabulary) as the 2025 wire seam.
3235
export * from './util/schema';
3336
export * from './util/standardSchema';
3437
export * from './util/zodCompat';
3538
export { codecForVersion, MODERN_WIRE_REVISION } from './wire/codec';
39+
export { normalizeContentlessToolResult, TOOL_RESULT_FOREIGN_FAMILY_KEYS } from './wire/resultFamilies';
3640

3741
// Validator provider classes stay subpath-only. Re-exporting them here, even as
3842
// `type`, can make generated client/server root declarations advertise

packages/core-internal/src/types/guards.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => J
8484
* @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise.
8585
*/
8686
export const isCallToolResult = (value: unknown): value is CallToolResult => {
87-
if (typeof value !== 'object' || value === null || !('content' in value)) return false;
87+
// content === undefined covers an explicit undefined key too: the schema's
88+
// .default([]) would parse it, but the narrowed type requires the array.
89+
if (typeof value !== 'object' || value === null || (value as { content?: unknown }).content === undefined) return false;
8890
return CallToolResultSchema.safeParse(value).success;
8991
};
9092

packages/core-internal/src/types/schemas.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1381,8 +1381,11 @@ export const CallToolResultSchema = ResultSchema.extend({
13811381
*
13821382
* If the `Tool` does not define an outputSchema, this field MUST be present in the result.
13831383
* Required on the wire per the specification (it may be an empty array).
1384+
*
1385+
* Parse-tolerant: absent `content` defaults to `[]` (v1 parity — deployed
1386+
* servers omit it alongside `structuredContent`).
13841387
*/
1385-
content: z.array(ContentBlockSchema),
1388+
content: z.array(ContentBlockSchema).default([]),
13861389

13871390
/**
13881391
* Structured tool output.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Result-family keys that must never default into a `{content: []}` tools/call
3+
* success. Shared by the 2025 wire-seam schema and server normalization.
4+
* Leaf module (like `textFallback.ts`): imported by registry/server paths, so
5+
* it must NOT import from `./codec.js` — that would close a runtime cycle.
6+
*/
7+
export const TOOL_RESULT_FOREIGN_FAMILY_KEYS = ['task', 'inputRequests', 'requestState'] as const;
8+
9+
/**
10+
* Single owner of the v1-parity ruling: a plain-object tool result without `content` (and
11+
* without foreign-family keys) gains `content: []`. Shared by the 2025 wire seam and server-side handler normalization.
12+
*/
13+
export function normalizeContentlessToolResult(value: unknown): unknown {
14+
if (
15+
value === null ||
16+
typeof value !== 'object' ||
17+
Array.isArray(value) ||
18+
(value as { content?: unknown }).content !== undefined ||
19+
TOOL_RESULT_FOREIGN_FAMILY_KEYS.some(key => key in value)
20+
) {
21+
return value;
22+
}
23+
return { ...value, content: [] };
24+
}

packages/core-internal/src/wire/rev2025-11-25/codec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ export const rev2025Codec: WireCodec = {
105105

106106
decodeResult(_method: string, raw: unknown): DecodedResult {
107107
// Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is
108-
// dropped before validation, whatever its value. There is no
109-
// discrimination on this era — `resultType` carries no meaning here.
108+
// dropped before validation, whatever its value. Validation judges the
109+
// husk — the registry wire-seam schema on the plain path, the caller's
110+
// schema on the explicit path (task interop).
110111
if (isPlainObject(raw) && 'resultType' in raw) {
111112
const stripped = { ...raw };
112113
delete stripped['resultType'];

packages/core-internal/src/wire/rev2025-11-25/registry.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@
2121
* shells, `resultType`, the `_meta` envelope) has NO entry and NO code path
2222
* here — the inverse-leak guarantee is physical absence, not discipline.
2323
*/
24-
import type * as z from 'zod/v4';
24+
import * as z from 'zod/v4';
2525

2626
import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types';
27+
import { normalizeContentlessToolResult, TOOL_RESULT_FOREIGN_FAMILY_KEYS } from '../resultFamilies';
2728
import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas';
2829
import {
2930
CallToolRequestSchema,
@@ -106,6 +107,31 @@ type Rev2025TypedRequestMethod = Extract<RequestMethod, Rev2025RequestMethod>;
106107
// no key may fall outside it (no `tasks/*` entries — the task methods are
107108
// 2025-11-25 wire vocabulary with no SDK runtime; callers needing task
108109
// interop pass an explicit schema).
110+
/**
111+
* Wire seam: owns both halves of the v1-parity ruling — the guard (a content-less body
112+
* carrying another result family's keys fails loudly; the era is frozen so the key list is
113+
* complete) and the tolerance (`content` defaults to `[]`). The era file stays twin-conformant.
114+
*/
115+
export const CallToolResultWireSchema = z
116+
.unknown()
117+
.superRefine((value, ctx) => {
118+
// content === undefined covers both an absent key and an explicit
119+
// undefined from server-side authoring objects.
120+
if (typeof value !== 'object' || value === null || Array.isArray(value) || (value as Record<string, unknown>).content !== undefined)
121+
return;
122+
for (const key of TOOL_RESULT_FOREIGN_FAMILY_KEYS) {
123+
if (key in value) {
124+
ctx.addIssue({
125+
code: 'custom',
126+
message: `content is required when the body carries '${key}' — another result family cannot default into an empty tools/call success`
127+
});
128+
return;
129+
}
130+
}
131+
})
132+
.transform(normalizeContentlessToolResult)
133+
.pipe(CallToolResultSchema);
134+
109135
const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType<ResultTypeMap[M]> } = {
110136
ping: EmptyResultSchema,
111137
initialize: InitializeResultSchema,
@@ -118,7 +144,7 @@ const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType<Resu
118144
'resources/read': ReadResourceResultSchema,
119145
'resources/subscribe': EmptyResultSchema,
120146
'resources/unsubscribe': EmptyResultSchema,
121-
'tools/call': CallToolResultSchema,
147+
'tools/call': CallToolResultWireSchema,
122148
'tools/list': ListToolsResultSchema,
123149
'sampling/createMessage': CreateMessageResultWithToolsSchema,
124150
'elicitation/create': ElicitResultSchema,

0 commit comments

Comments
 (0)