@@ -413,6 +413,19 @@ interface ListenStateEntry {
413413 settle : ( outcome : { ack : SubscriptionFilter } | { cause : 'local' | 'remote' ; error ?: Error } ) => void ;
414414}
415415
416+ /**
417+ * Per-tool result of compiling an `outputSchema` (SEP-2106). Stored on the
418+ * response-cache substrate's stamp-keyed `name → validator` index so it
419+ * inherits that substrate's invalidation lifecycle (`list_changed` evicts,
420+ * a refetched `tools/list` re-derives, `resetForReconnect` clears) — no
421+ * parallel map to keep in sync.
422+ *
423+ * @internal
424+ */
425+ type OutputSchemaCompileResult =
426+ | { ok : true ; validator : JsonSchemaValidator < unknown > }
427+ | { ok : false ; validator ?: undefined ; compileError : unknown } ;
428+
416429/**
417430 * An MCP client on top of a pluggable transport.
418431 *
@@ -453,13 +466,6 @@ export class Client extends Protocol<ClientContext> {
453466 private _capabilities : ClientCapabilities ;
454467 private _instructions ?: string ;
455468 private _jsonSchemaValidator : jsonSchemaValidator ;
456- /**
457- * Per-tool compile errors for `outputSchema`, captured by {@linkcode _compileOutputValidator}
458- * (the response-cache substrate's compile callback) so one bad schema does not poison the whole
459- * list (SEP-2106; baseline-bug #14). Surfaced by {@linkcode callTool} as a typed
460- * `InvalidParams` error before the request is sent.
461- */
462- private _cachedToolOutputCompileErrors : Map < string , unknown > = new Map ( ) ;
463469 /**
464470 * The response-cache substrate. Owns the backing store, the per-method
465471 * eviction-generation counter, the user-supplied/default flag, and the
@@ -531,7 +537,6 @@ export class Client extends Protocol<ClientContext> {
531537 clearTimeout ( timer ) ;
532538 }
533539 this . _listChangedDebounceTimers . clear ( ) ;
534- this . _cachedToolOutputCompileErrors . clear ( ) ;
535540 // A user-supplied store is NOT cleared on reconnect/close — that would
536541 // defeat the only reason to supply one. The per-instance default IS
537542 // cleared (it is connection-scoped); derived indices and the
@@ -1649,25 +1654,28 @@ export class Client extends Protocol<ClientContext> {
16491654 }
16501655
16511656 /**
1652- * Compile a single tool's `outputSchema` (or `undefined` when absent /
1653- * uncompilable) — the caller-supplied-definition path of
1654- * {@linkcode callTool} so an explicit `options.toolDefinition` is the
1655- * source for BOTH mirroring AND output validation. Also passed as the
1656- * compile callback to {@linkcode ClientResponseCache.outputValidator} so
1657- * the cache class stays free of any validator-provider dependency.
1657+ * Compile a single tool's `outputSchema`. Passed as the compile callback to
1658+ * {@linkcode ClientResponseCache.outputValidator} so the cache class stays
1659+ * free of any validator-provider dependency, and called directly for the
1660+ * `options.toolDefinition` path of {@linkcode callTool} (a one-off
1661+ * caller-supplied definition is compiled in isolation and never enters the
1662+ * cache, so it cannot poison the listed tool of the same name).
1663+ *
1664+ * Returns `undefined` when the tool has no `outputSchema`, or a
1665+ * discriminated `{ok}` result otherwise. SEP-2106: ANY throw — from the
1666+ * ref/bounds/dialect guard or from the underlying engine — is captured as
1667+ * `{ok: false, compileError}` so one bad schema does not poison the rest
1668+ * of the listing; `callTool()` surfaces it as a typed `InvalidParams`
1669+ * error before the request. The `{ok}` discriminator (not
1670+ * `compileError !== undefined`) means a custom provider that does
1671+ * `throw undefined` is still treated as a captured failure.
16581672 */
1659- private _compileOutputValidator ( tool : Tool ) : JsonSchemaValidator < unknown > | undefined {
1673+ private _compileOutputValidator ( tool : Tool ) : OutputSchemaCompileResult | undefined {
16601674 if ( ! tool . outputSchema ) return undefined ;
16611675 try {
1662- const validator = this . _jsonSchemaValidator . getValidator ( tool . outputSchema as JsonSchemaType ) ;
1663- this . _cachedToolOutputCompileErrors . delete ( tool . name ) ;
1664- return validator ;
1676+ return { ok : true , validator : this . _jsonSchemaValidator . getValidator ( tool . outputSchema as JsonSchemaType ) } ;
16651677 } catch ( error ) {
1666- // SEP-2106: ANY throw — from the ref/bounds/dialect guard or from the underlying
1667- // engine — is captured per-tool so one bad schema does not poison the rest of the
1668- // listing; callTool() surfaces it as a typed InvalidParams error before the request.
1669- this . _cachedToolOutputCompileErrors . set ( tool . name , error ) ;
1670- return undefined ;
1678+ return { ok : false , compileError : error } ;
16711679 }
16721680 }
16731681
@@ -2171,23 +2179,23 @@ export class Client extends Protocol<ClientContext> {
21712179 // surfaced here, per-tool, without a wasted network round-trip and server-side handler
21722180 // execution. When the caller supplied `toolDefinition`, that definition is the source for
21732181 // BOTH the `Mcp-Param-*` mirroring above AND output validation — the two derived views
2174- // must agree. The cache read is guarded: a custom store whose `get()` rejects routes to
2175- // `onerror` and degrades to skipping validation (same outcome as a cold cache).
2176- const validator =
2182+ // must agree — and is compiled in isolation (never written to the cache). The cache read
2183+ // is guarded: a custom store whose `get()` rejects routes to `onerror` and degrades to
2184+ // skipping validation (same outcome as a cold cache).
2185+ const compiled =
21772186 options ?. toolDefinition === undefined
21782187 ? await this . _cache
21792188 . outputValidator ( params . name , tool => this . _compileOutputValidator ( tool ) )
21802189 . catch ( error => void this . _reportStoreError ( error ) )
21812190 : this . _compileOutputValidator ( options . toolDefinition ) ;
2182- // `.has()` (not `.get()!==undefined`) so a custom provider that does `throw undefined` is
2183- // still treated as a captured failure.
2184- if ( this . _cachedToolOutputCompileErrors . has ( params . name ) ) {
2185- const compileError = this . _cachedToolOutputCompileErrors . get ( params . name ) ;
2191+ if ( compiled !== undefined && ! compiled . ok ) {
2192+ const compileError = compiled . compileError ;
21862193 const message = ( compileError instanceof Error ? compileError . message : String ( compileError ) ) . slice ( 0 , 200 ) ;
21872194 throw new ProtocolError ( ProtocolErrorCode . InvalidParams , `Tool '${ params . name } ' has an invalid outputSchema: ${ message } ` , {
21882195 reason : compileError instanceof SchemaCompileError ? compileError . reason . kind : 'invalid-schema'
21892196 } ) ;
21902197 }
2198+ const validator = compiled ?. validator ;
21912199
21922200 // The method-keyed request() path validates the era registry's plain
21932201 // CallToolResult schema — with the result map aligned to the typed
0 commit comments