Skip to content

Commit 6ec3071

Browse files
fix(client,core,server): #2337 review — stale compile-error cache, typeless-root stamping, result mutation, dead-surface JSDoc
1 parent 6578c9d commit 6ec3071

11 files changed

Lines changed: 205 additions & 80 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,19 @@ interface ListenStateEntry {
366366
settle: (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }) => void;
367367
}
368368

369+
/**
370+
* Per-tool result of compiling an `outputSchema` (SEP-2106). Stored on the
371+
* response-cache substrate's stamp-keyed `name → validator` index so it
372+
* inherits that substrate's invalidation lifecycle (`list_changed` evicts,
373+
* a refetched `tools/list` re-derives, `resetForReconnect` clears) — no
374+
* parallel map to keep in sync.
375+
*
376+
* @internal
377+
*/
378+
type OutputSchemaCompileResult =
379+
| { ok: true; validator: JsonSchemaValidator<unknown> }
380+
| { ok: false; validator?: undefined; compileError: unknown };
381+
369382
/**
370383
* An MCP client on top of a pluggable transport.
371384
*
@@ -406,13 +419,6 @@ export class Client extends Protocol<ClientContext> {
406419
private _capabilities: ClientCapabilities;
407420
private _instructions?: string;
408421
private _jsonSchemaValidator: jsonSchemaValidator;
409-
/**
410-
* Per-tool compile errors for `outputSchema`, captured by {@linkcode _compileOutputValidator}
411-
* (the response-cache substrate's compile callback) so one bad schema does not poison the whole
412-
* list (SEP-2106; baseline-bug #14). Surfaced by {@linkcode callTool} as a typed
413-
* `InvalidParams` error before the request is sent.
414-
*/
415-
private _cachedToolOutputCompileErrors: Map<string, unknown> = new Map();
416422
/**
417423
* The response-cache substrate. Owns the backing store, the per-method
418424
* eviction-generation counter, the user-supplied/default flag, and the
@@ -483,7 +489,6 @@ export class Client extends Protocol<ClientContext> {
483489
clearTimeout(timer);
484490
}
485491
this._listChangedDebounceTimers.clear();
486-
this._cachedToolOutputCompileErrors.clear();
487492
// A user-supplied store is NOT cleared on reconnect/close — that would
488493
// defeat the only reason to supply one. The per-instance default IS
489494
// cleared (it is connection-scoped); derived indices and the
@@ -1501,25 +1506,28 @@ export class Client extends Protocol<ClientContext> {
15011506
}
15021507

15031508
/**
1504-
* Compile a single tool's `outputSchema` (or `undefined` when absent /
1505-
* uncompilable) — the caller-supplied-definition path of
1506-
* {@linkcode callTool} so an explicit `options.toolDefinition` is the
1507-
* source for BOTH mirroring AND output validation. Also passed as the
1508-
* compile callback to {@linkcode ClientResponseCache.outputValidator} so
1509-
* the cache class stays free of any validator-provider dependency.
1509+
* Compile a single tool's `outputSchema`. Passed as the compile callback to
1510+
* {@linkcode ClientResponseCache.outputValidator} so the cache class stays
1511+
* free of any validator-provider dependency, and called directly for the
1512+
* `options.toolDefinition` path of {@linkcode callTool} (a one-off
1513+
* caller-supplied definition is compiled in isolation and never enters the
1514+
* cache, so it cannot poison the listed tool of the same name).
1515+
*
1516+
* Returns `undefined` when the tool has no `outputSchema`, or a
1517+
* discriminated `{ok}` result otherwise. SEP-2106: ANY throw — from the
1518+
* ref/bounds/dialect guard or from the underlying engine — is captured as
1519+
* `{ok: false, compileError}` so one bad schema does not poison the rest
1520+
* of the listing; `callTool()` surfaces it as a typed `InvalidParams`
1521+
* error before the request. The `{ok}` discriminator (not
1522+
* `compileError !== undefined`) means a custom provider that does
1523+
* `throw undefined` is still treated as a captured failure.
15101524
*/
1511-
private _compileOutputValidator(tool: Tool): JsonSchemaValidator<unknown> | undefined {
1525+
private _compileOutputValidator(tool: Tool): OutputSchemaCompileResult | undefined {
15121526
if (!tool.outputSchema) return undefined;
15131527
try {
1514-
const validator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType);
1515-
this._cachedToolOutputCompileErrors.delete(tool.name);
1516-
return validator;
1528+
return { ok: true, validator: this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType) };
15171529
} catch (error) {
1518-
// SEP-2106: ANY throw — from the ref/bounds/dialect guard or from the underlying
1519-
// engine — is captured per-tool so one bad schema does not poison the rest of the
1520-
// listing; callTool() surfaces it as a typed InvalidParams error before the request.
1521-
this._cachedToolOutputCompileErrors.set(tool.name, error);
1522-
return undefined;
1530+
return { ok: false, compileError: error };
15231531
}
15241532
}
15251533

@@ -1967,23 +1975,23 @@ export class Client extends Protocol<ClientContext> {
19671975
// surfaced here, per-tool, without a wasted network round-trip and server-side handler
19681976
// execution. When the caller supplied `toolDefinition`, that definition is the source for
19691977
// BOTH the `Mcp-Param-*` mirroring above AND output validation — the two derived views
1970-
// must agree. The cache read is guarded: a custom store whose `get()` rejects routes to
1971-
// `onerror` and degrades to skipping validation (same outcome as a cold cache).
1972-
const validator =
1978+
// must agree — and is compiled in isolation (never written to the cache). The cache read
1979+
// is guarded: a custom store whose `get()` rejects routes to `onerror` and degrades to
1980+
// skipping validation (same outcome as a cold cache).
1981+
const compiled =
19731982
options?.toolDefinition === undefined
19741983
? await this._cache
19751984
.outputValidator(params.name, tool => this._compileOutputValidator(tool))
19761985
.catch(error => void this._reportStoreError(error))
19771986
: this._compileOutputValidator(options.toolDefinition);
1978-
// `.has()` (not `.get()!==undefined`) so a custom provider that does `throw undefined` is
1979-
// still treated as a captured failure.
1980-
if (this._cachedToolOutputCompileErrors.has(params.name)) {
1981-
const compileError = this._cachedToolOutputCompileErrors.get(params.name);
1987+
if (compiled !== undefined && !compiled.ok) {
1988+
const compileError = compiled.compileError;
19821989
const message = (compileError instanceof Error ? compileError.message : String(compileError)).slice(0, 200);
19831990
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool '${params.name}' has an invalid outputSchema: ${message}`, {
19841991
reason: compileError instanceof SchemaCompileError ? compileError.reason.kind : 'invalid-schema'
19851992
});
19861993
}
1994+
const validator = compiled?.validator;
19871995

19881996
// The method-keyed request() path validates the era registry's plain
19891997
// CallToolResult schema — with the result map aligned to the typed

packages/client/src/client/responseCache.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -281,9 +281,14 @@ export class ClientResponseCache {
281281
* `Client` passes its `_jsonSchemaValidator` wrapper) so this
282282
* class carries no validator-provider dependency. One tool's uncompilable
283283
* `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`)
284-
* must not poison every other tool's `callTool` — the callback returns
285-
* `undefined` (and warns naming the offender) for the bad one and the
286-
* index simply omits it.
284+
* must not poison every other tool's `callTool` — the callback isolates
285+
* that compile error per tool by returning a per-tool error variant which
286+
* the index stores alongside the good ones, and `callTool` surfaces it as
287+
* a typed `InvalidParams` only for that name. Because the error is held on
288+
* this stamp-keyed substrate (not a parallel map), it inherits the
289+
* substrate's invalidation lifecycle: a `list_changed` eviction drops it,
290+
* a refetched `tools/list` re-derives it, and `resetForReconnect` clears
291+
* the lot.
287292
*/
288293
async outputValidator<V>(name: string, compile: (tool: Tool) => V | undefined): Promise<V | undefined> {
289294
const entry = await this._store.get({ method: 'tools/list' });
@@ -294,10 +299,8 @@ export class ClientResponseCache {
294299
if (this._toolOutputValidatorIndex?.stamp !== entry.stamp) {
295300
const byName = new Map<string, unknown>();
296301
for (const tool of (entry.value as ListToolsResult).tools) {
297-
if (tool.outputSchema) {
298-
const validator = compile(tool);
299-
if (validator !== undefined) byName.set(tool.name, validator);
300-
}
302+
const compiled = compile(tool);
303+
if (compiled !== undefined) byName.set(tool.name, compiled);
301304
}
302305
this._toolOutputValidatorIndex = { stamp: entry.stamp, byName };
303306
}

packages/client/test/client/jsonSchemaValidatorOverride.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,88 @@ describe('client JSON Schema validator overrides', () => {
114114
await serverTransport.close();
115115
});
116116

117+
describe('outputSchema compile-error lifecycle (substrate-held; no parallel map)', () => {
118+
// SEP-2106 §invalid-outputSchema: a tool whose outputSchema fails to compile is
119+
// surfaced as a typed InvalidParams BEFORE the request is sent. The compile error is
120+
// held on the response-cache substrate's stamp-keyed `name → validator` index, so it
121+
// inherits that substrate's invalidation lifecycle — a refetched `tools/list` re-derives
122+
// it from scratch (no stale-entry bug when the server fixes the tool by removing the
123+
// schema). The caller-supplied `toolDefinition` path is compiled in isolation and never
124+
// touches the cache, so a one-off bad definition cannot poison the listed tool.
125+
async function connectMutableToolsClient(getTools: () => unknown[]) {
126+
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
127+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
128+
serverTransport.onmessage = async message => {
129+
if (!('method' in message) || !('id' in message)) return;
130+
if (message.method === 'initialize') {
131+
await serverTransport.send({
132+
jsonrpc: '2.0',
133+
id: message.id,
134+
result: {
135+
protocolVersion: LATEST_PROTOCOL_VERSION,
136+
capabilities: { tools: {} },
137+
serverInfo: { name: 'test-server', version: '1.0.0' }
138+
}
139+
});
140+
} else if (message.method === 'tools/list') {
141+
await serverTransport.send({
142+
jsonrpc: '2.0',
143+
id: message.id,
144+
result: { tools: getTools() }
145+
} satisfies JSONRPCMessage);
146+
} else if (message.method === 'tools/call') {
147+
await serverTransport.send({
148+
jsonrpc: '2.0',
149+
id: message.id,
150+
result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { count: 1 } }
151+
} satisfies JSONRPCMessage);
152+
}
153+
};
154+
await Promise.all([client.connect(clientTransport), serverTransport.start()]);
155+
return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) };
156+
}
157+
158+
// A network `$ref` is rejected at compile time by the SDK's built-in ref guard.
159+
const BAD_SCHEMA = { type: 'object', $ref: 'https://example.invalid/schema.json' } as const;
160+
const GOOD_SCHEMA = { type: 'object', properties: { count: { type: 'number' } } } as const;
161+
162+
test('re-advertising a tool WITHOUT the bad outputSchema clears the captured failure', async () => {
163+
let tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA }];
164+
const { client, close } = await connectMutableToolsClient(() => tools);
165+
166+
await client.listTools();
167+
await expect(client.callTool({ name: 't' })).rejects.toThrow(/invalid outputSchema/);
168+
169+
// Server fixes the tool by removing outputSchema entirely; refetched `tools/list`
170+
// re-derives the index from scratch — no stale compile-error entry survives.
171+
tools = [{ name: 't', inputSchema: { type: 'object' } }];
172+
await client.listTools();
173+
await expect(client.callTool({ name: 't' })).resolves.toMatchObject({
174+
content: [{ type: 'text', text: 'ok' }]
175+
});
176+
177+
await close();
178+
});
179+
180+
test('a one-off `toolDefinition` with a bad outputSchema does not poison the listed tool', async () => {
181+
const tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: GOOD_SCHEMA }];
182+
const { client, close } = await connectMutableToolsClient(() => tools);
183+
184+
await client.listTools();
185+
await expect(
186+
client.callTool({ name: 't' }, { toolDefinition: { name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA } })
187+
).rejects.toThrow(/invalid outputSchema/);
188+
189+
// Subsequent plain callTool of the same name (against the cached, valid listed
190+
// schema) succeeds — the one-off definition never entered the cache.
191+
await expect(client.callTool({ name: 't' })).resolves.toMatchObject({
192+
structuredContent: { count: 1 }
193+
});
194+
195+
await close();
196+
});
197+
});
198+
117199
test('fromJsonSchema uses an explicitly supplied custom validator', async () => {
118200
const validator = new RecordingValidator();
119201
const schema: JsonSchemaType = {

packages/core/src/util/standardSchema.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,18 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
206206
}
207207
if (io === 'output') {
208208
// SEP-2106: outputSchema may have any JSON Schema root type (array, string, …) — no
209-
// non-object throw. A typeless root keeps the pre-SEP `type:'object'` default so the
210-
// 2025-era wire bytes are unchanged for object/typeless schemas; an explicit non-object
211-
// `type` is returned as-is (and gated by the legacy projection drop).
212-
return result.type === undefined ? { type: 'object', ...result } : result;
209+
// non-object throw. An explicit `type` (object or not) is returned as-is. A typeless
210+
// root is returned as-is when it is NOT provably object-shaped — e.g. a `z.union(...)`
211+
// emits `{anyOf:[…]}`, and stamping `type:'object'` there would be self-contradictory.
212+
// Only when the root is typeless AND object-shaped (carries `properties` /
213+
// `patternProperties` / `additionalProperties` / `required`) do we default
214+
// `type:'object'` so the 2025-era wire bytes are unchanged for the pre-SEP object
215+
// schemas. The legacy projection drop gates anything that does not end up
216+
// `type:'object'`.
217+
if (result.type !== undefined) return result;
218+
const isObjectShaped =
219+
'properties' in result || 'patternProperties' in result || 'additionalProperties' in result || 'required' in result;
220+
return isObjectShaped ? { type: 'object', ...result } : result;
213221
}
214222
if (result.type !== undefined && result.type !== 'object') {
215223
throw new Error(

packages/core/src/validators/ajvProvider.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,16 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
8989
this._ajv = ajv ?? createDefaultAjvInstance();
9090
}
9191

92-
/** Supported `$schema` dialect identifiers. */
92+
/**
93+
* JSON Schema dialects this provider can validate, as short identifiers. Advisory/diagnostic
94+
* only — the SDK does not branch on it.
95+
*/
9396
readonly supportedDialects = ['2020-12'] as const;
9497

95-
/** @inheritdoc */
98+
/**
99+
* The dialect this provider applies when a schema declares no `$schema`.
100+
* Advisory/diagnostic only — the SDK does not branch on it.
101+
*/
96102
readonly defaultDialect = '2020-12';
97103

98104
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {

packages/core/src/validators/cfWorkerProvider.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,17 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
6868
this.draft = options?.draft;
6969
}
7070

71-
/** @inheritdoc */
71+
/**
72+
* JSON Schema dialects this provider can validate, as short identifiers. Advisory/diagnostic
73+
* only — the SDK does not branch on it.
74+
*/
7275
readonly supportedDialects = ['2020-12'] as const;
7376

74-
/** @inheritdoc */
75-
get defaultDialect(): string {
76-
return this.draft ?? '2020-12';
77-
}
77+
/**
78+
* The dialect this provider applies when a schema declares no `$schema` and no `{draft}` is
79+
* forced. Advisory/diagnostic only — the SDK does not branch on it.
80+
*/
81+
readonly defaultDialect = '2020-12';
7882

7983
/**
8084
* Create a validator for the given JSON Schema

packages/core/src/validators/schemaBounds.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ export const DEFAULT_MAX_SCHEMA_DEPTH = 64;
2929
export const DEFAULT_MAX_SUBSCHEMA_COUNT = 10_000;
3030

3131
/**
32-
* JSON Schema 2020-12 applicator keywords whose value is one or more subschemas (plus the draft-07
33-
* spellings `definitions` / `additionalItems`). Exported for consumers that need to descend through
34-
* schema-valued positions only — notably the SEP-2243 `x-mcp-header` scan.
32+
* Vocabulary list of JSON Schema keywords whose values are themselves schemas. Currently consumed
33+
* only within this module's tests; intended as the single source for the SEP-2243 `x-mcp-header`
34+
* reachability scan in `mcpParamHeaders.ts` (which today carries its own private list —
35+
* consolidation is a follow-up).
3536
*
3637
* NOTE: {@link assertSchemaSafeToCompile} does **not** consult this list; its walk is intentionally
3738
* exhaustive (every key) so a future or vendor keyword cannot smuggle a `$ref` past the guard.

packages/core/src/validators/types.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,4 @@ export interface jsonSchemaValidator {
6060
* @returns A validator function that can be called multiple times
6161
*/
6262
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T>;
63-
64-
/**
65-
* JSON Schema dialects this provider can validate, as short identifiers (e.g. `'2020-12'`,
66-
* `'draft-07'`). When present, the SDK uses this to surface a graceful
67-
* {@link SchemaCompileError} for an unrecognised `$schema` declaration; when absent the SDK
68-
* does not dialect-check on the provider's behalf.
69-
*/
70-
readonly supportedDialects?: readonly string[];
71-
72-
/**
73-
* The dialect (from {@link supportedDialects}) this provider applies when a schema declares no
74-
* `$schema`. Advisory — surfaced for diagnostics; the SDK does not branch on it.
75-
*/
76-
readonly defaultDialect?: string;
7763
}

packages/core/test/util/standardSchema.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,24 @@ describe('standardSchemaToJsonSchema', () => {
3939
expect(keys.filter(k => k === 'type')).toHaveLength(1);
4040
expect(result.type).toBe('object');
4141
});
42+
43+
describe("io='output' (SEP-2106)", () => {
44+
test('union of primitives is returned as-is without type:object stamping', () => {
45+
const result = standardSchemaToJsonSchema(z.union([z.string(), z.number()]), 'output');
46+
// zod emits a typeless `{anyOf:[…]}` — stamping `type:'object'` here would be
47+
// self-contradictory; the legacy projection drop gates it instead.
48+
expect(result.type).toBeUndefined();
49+
expect(result.anyOf).toBeDefined();
50+
});
51+
52+
test('nullable object is not force-stamped type:object', () => {
53+
const result = standardSchemaToJsonSchema(z.object({}).nullable(), 'output');
54+
expect(result.type).not.toBe('object');
55+
});
56+
57+
test('object schemas still get type:object', () => {
58+
const result = standardSchemaToJsonSchema(z.object({ a: z.string() }), 'output');
59+
expect(result.type).toBe('object');
60+
});
61+
});
4262
});

0 commit comments

Comments
 (0)