@@ -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
0 commit comments