fix(core): restore the v1 content default for tools/call results on the legacy era (#2456)
This commit is contained in:
parent
3834921074
commit
44797d7779
24 changed files with 347 additions and 96 deletions
14
.changeset/calltoolresult-content-default.md
Normal file
14
.changeset/calltoolresult-content-default.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
'@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.
|
||||
|
||||
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.
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ export default [
|
|||
{
|
||||
// Wire-layer isolation, outbound direction: nothing outside src/wire/ may
|
||||
// reach into a wire revision module. The wire layer's only public surface
|
||||
// is src/wire/codec.ts (the WireCodec interface) and src/wire/bootstrap.ts.
|
||||
// is src/wire/codec.ts (the WireCodec interface), src/wire/bootstrap.ts,
|
||||
// and the leaf result-family module src/wire/resultFamilies.ts (the shared
|
||||
// tools/call-result ruling, re-exported on the barrel).
|
||||
// test/wire/layeringInvariants.test.ts re-derives the same invariant with
|
||||
// zero exceptions. Type-only imports are exempted at the lint layer (a
|
||||
// type-only crossing is erased at runtime), but the test allows none.
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ export * from './util/inMemory';
|
|||
// 2026-only seam runs in. NOTHING per-revision (registries, codec objects,
|
||||
// per-revision schemas) is ever exported on this barrel — sibling packages
|
||||
// reach the wire layer ONLY through `codecForVersion`'s function-only
|
||||
// `WireCodec` surface.
|
||||
// `WireCodec` surface. Sole exemption: the shared result-family ruling
|
||||
// (`wire/resultFamilies.ts`), era-independent by design — the server's
|
||||
// authoring normalization and the e2e wire sniffer apply the same ruling
|
||||
// (and name the same vocabulary) as the 2025 wire seam.
|
||||
export * from './util/schema';
|
||||
export * from './util/standardSchema';
|
||||
export * from './util/zodCompat';
|
||||
export { codecForVersion, MODERN_WIRE_REVISION } from './wire/codec';
|
||||
export { normalizeContentlessToolResult, 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
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => J
|
|||
* @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise.
|
||||
*/
|
||||
export const isCallToolResult = (value: unknown): value is CallToolResult => {
|
||||
if (typeof value !== 'object' || value === null || !('content' in value)) return false;
|
||||
// content === undefined covers an explicit undefined key too: the schema's
|
||||
// .default([]) would parse it, but the narrowed type requires the array.
|
||||
if (typeof value !== 'object' || value === null || (value as { content?: unknown }).content === undefined) return false;
|
||||
return CallToolResultSchema.safeParse(value).success;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
|
||||
/**
|
||||
* Structured tool output.
|
||||
|
|
|
|||
24
packages/core-internal/src/wire/resultFamilies.ts
Normal file
24
packages/core-internal/src/wire/resultFamilies.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Single owner of the v1-parity ruling: a plain-object tool result without `content` (and
|
||||
* without foreign-family keys) gains `content: []`. Shared by the 2025 wire seam and server-side handler normalization.
|
||||
*/
|
||||
export function normalizeContentlessToolResult(value: unknown): unknown {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(value as { content?: unknown }).content !== undefined ||
|
||||
TOOL_RESULT_FOREIGN_FAMILY_KEYS.some(key => key in value)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return { ...value, content: [] };
|
||||
}
|
||||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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 { normalizeContentlessToolResult, TOOL_RESULT_FOREIGN_FAMILY_KEYS } from '../resultFamilies';
|
||||
import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
|
|
@ -106,6 +107,31 @@ 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: owns both halves of the v1-parity ruling — the guard (a content-less body
|
||||
* carrying another result family's keys fails loudly; the era is frozen so the key list is
|
||||
* complete) and the tolerance (`content` defaults to `[]`). The era file stays twin-conformant.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
})
|
||||
.transform(normalizeContentlessToolResult)
|
||||
.pipe(CallToolResultSchema);
|
||||
|
||||
const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType<ResultTypeMap[M]> } = {
|
||||
ping: EmptyResultSchema,
|
||||
initialize: InitializeResultSchema,
|
||||
|
|
@ -118,7 +144,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,
|
||||
|
|
|
|||
109
packages/core-internal/test/shared/contentDefaultParity.test.ts
Normal file
109
packages/core-internal/test/shared/contentDefaultParity.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -137,12 +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 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.
|
||||
const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY);
|
||||
const outcome = await settle(protocol);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ describe('isCallToolResult', () => {
|
|||
expect(isCallToolResult({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an explicit content: undefined (the schema default parses it, but the narrowed type requires the array)', () => {
|
||||
expect(isCallToolResult({ content: undefined, structuredContent: { ok: true } })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a result with content', () => {
|
||||
expect(
|
||||
isCallToolResult({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import {
|
|||
ListToolsResultSchema as Wire2026ListToolsResultSchema,
|
||||
RequestMetaEnvelopeSchema
|
||||
} from '../../src/wire/rev2026-07-28/schemas';
|
||||
import { getResultSchema as getRev2025ResultSchema } from '../../src/wire/rev2025-11-25/registry';
|
||||
import { CallToolResultSchema as Wire2025CallToolResultSchema } from '../../src/wire/rev2025-11-25/schemas';
|
||||
import type {
|
||||
CallToolResult,
|
||||
CompleteResult,
|
||||
|
|
@ -139,18 +141,17 @@ describe('typed result schemas are loose', () => {
|
|||
expect((parsed as Record<string, unknown>).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`. 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 });
|
||||
const explicit = CallToolResultSchema.parse({ content: [], structuredContent: { ok: true } });
|
||||
expect(explicit.content).toEqual([]);
|
||||
});
|
||||
|
||||
test('CallToolResult preserves isError and sibling members through the parse', () => {
|
||||
|
|
@ -167,6 +168,15 @@ describe('typed result schemas are loose', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('2025 wire layering: era file spec-strict, era seam tolerant', () => {
|
||||
test('the era-schema file rejects a content-less CallToolResult body; the registry seam defaults it', () => {
|
||||
// Layering rule: era-schema files stay spec-verbatim (the CallToolResult twin requires content); the seam's v1-parity tolerance defaults CallToolResult-family bodies only — foreign-family results (task/inputRequests/requestState) are never defaulted.
|
||||
expect(Wire2025CallToolResultSchema.safeParse({ structuredContent: {} }).success).toBe(false);
|
||||
const seamParsed = getRev2025ResultSchema('tools/call')!.parse({ structuredContent: {} }) as { content: unknown };
|
||||
expect(seamParsed.content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completion result boundary', () => {
|
||||
test('the completion object is loose: unknown sibling fields are preserved', () => {
|
||||
const parsed = CompleteResultSchema.parse({ completion: { values: ['alpha'], extraField: 'kept' } });
|
||||
|
|
|
|||
|
|
@ -90,20 +90,20 @@ 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): 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(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<ContentBlock[]>();
|
||||
expectTypeOf(v.content).not.toEqualTypeOf<ContentBlock[] | undefined>();
|
||||
// The guard narrows to the INPUT type: content optional pre-parse.
|
||||
expectTypeOf(v.content).toEqualTypeOf<ContentBlock[] | undefined>();
|
||||
}
|
||||
void (0 as unknown as CallToolResult);
|
||||
// The parsed/public type keeps content required (z.output).
|
||||
expectTypeOf<CallToolResult['content']>().toEqualTypeOf<ContentBlock[]>();
|
||||
});
|
||||
|
||||
it('JSONValue / JSONObject — narrows to the JSON type, not unknown', () => {
|
||||
|
|
|
|||
|
|
@ -574,27 +574,19 @@ 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 fails the wire-seam schema — 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' } });
|
||||
// Decode passes bodies without resultType through (explicit-schema
|
||||
// task interop must work); the wire-seam schema does the refusing.
|
||||
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
|
||||
// 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 plain = getResultSchema('tools/call');
|
||||
expect(plain).toBe(CallToolResultSchema);
|
||||
const wireSeam = getResultSchema('tools/call');
|
||||
expect(
|
||||
plain!.safeParse({
|
||||
wireSeam!.safeParse({
|
||||
task: {
|
||||
taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86',
|
||||
status: 'working',
|
||||
|
|
@ -605,5 +597,7 @@ describe('T6 width-leak killed at both roots', () => {
|
|||
}
|
||||
}).success
|
||||
).toBe(false);
|
||||
// While a plain content-less tool result still defaults:
|
||||
expect(wireSeam!.safeParse({ structuredContent: { ok: true } }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
missingClientCapabilities,
|
||||
MissingRequiredClientCapabilityError,
|
||||
modernProtocolVersions,
|
||||
normalizeContentlessToolResult,
|
||||
parseSchema,
|
||||
Protocol,
|
||||
ProtocolError,
|
||||
|
|
@ -524,7 +525,12 @@ export class Server extends Protocol<ServerContext> {
|
|||
return result;
|
||||
}
|
||||
|
||||
const validationResult = codec.validateResult('tools/call', 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 = normalizeContentlessToolResult(result);
|
||||
|
||||
const validationResult = codec.validateResult('tools/call', normalizedResult);
|
||||
if (!validationResult.ok) {
|
||||
throw new ProtocolError(
|
||||
validationResult.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue