-
Notifications
You must be signed in to change notification settings - Fork 2k
fix(core): restore the v1 content default for tools/call results on the legacy era #2456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0c7eb02
4f00b91
91c3e7d
e4dc30c
3ae9e3d
dccc400
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| --- | ||
|
|
||
| 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The restored Extended reasoning...What the bug is The restored The code path that triggers it Zod applies a
Why existing code doesn't prevent it Pre-PR, Impact
Step-by-step proof
How to fix One-line change in 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. | ||
|
|
||
| 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; |
| 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 |
|---|---|---|
|
|
@@ -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
|
||
|
Comment on lines
139
to
+142
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Why the e2e attribution is now wrong (step-by-step)
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 How to fix
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); | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.