From a0350ac3d8f1e147d81ad4d6e267112276a94a1d Mon Sep 17 00:00:00 2001 From: harshasiddartha Date: Thu, 6 Aug 2026 14:14:59 +0530 Subject: [PATCH] fix(client): keep tool metadata intact when a listTools() refresh fails cacheToolMetadata() cleared the output-validator and task-support caches before compiling the replacement catalog. If a later output schema failed to compile, listTools() rejected but the client was left with empty or partially replaced metadata, so subsequent callTool() invocations skipped output validation and lost task-support information. Build the replacement Map/Set locally and publish them only once every tool has been processed, so a failed refresh leaves the metadata from the last successful listTools() in effect. --- src/client/index.ts | 18 ++++++--- test/client/index.test.ts | 85 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index ae0e698ec3..6621fca8be 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -785,26 +785,32 @@ export class Client< * Called after listTools() to pre-compile validators for better performance. */ private cacheToolMetadata(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); - this._cachedKnownTaskTools.clear(); - this._cachedRequiredTaskTools.clear(); + // Build the replacement collections separately, so that a schema which fails to + // compile leaves the metadata of the last successful listTools() untouched. + const toolOutputValidators = new Map>(); + const knownTaskTools = new Set(); + const requiredTaskTools = new Set(); for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); - this._cachedToolOutputValidators.set(tool.name, toolValidator); + toolOutputValidators.set(tool.name, toolValidator); } // If the tool supports task-based execution, cache that information const taskSupport = tool.execution?.taskSupport; if (taskSupport === 'required' || taskSupport === 'optional') { - this._cachedKnownTaskTools.add(tool.name); + knownTaskTools.add(tool.name); } if (taskSupport === 'required') { - this._cachedRequiredTaskTools.add(tool.name); + requiredTaskTools.add(tool.name); } } + + this._cachedToolOutputValidators = toolOutputValidators; + this._cachedKnownTaskTools = knownTaskTools; + this._cachedRequiredTaskTools = requiredTaskTools; } /** diff --git a/test/client/index.test.ts b/test/client/index.test.ts index f5c6a348d1..fcc2c3b5a4 100644 --- a/test/client/index.test.ts +++ b/test/client/index.test.ts @@ -2333,6 +2333,91 @@ describe('outputSchema validation', () => { /Structured content does not match the tool's output schema/ ); }); + + /*** + * Test: Keep Previously Cached Metadata when a Later listTools() Fails to Compile + */ + test('should keep previously cached tool metadata when a later listTools() fails to compile', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + const validTools: Tool[] = [ + { + name: 'validated-tool', + inputSchema: { type: 'object', properties: {} }, + outputSchema: { + type: 'object', + properties: { result: { type: 'string' } }, + required: ['result'], + additionalProperties: false + } + }, + { + name: 'task-only-tool', + inputSchema: { type: 'object', properties: {} }, + execution: { taskSupport: 'required' } + } + ]; + + // Accepted by ListToolsResultSchema (the root is a valid object schema), but + // rejected by the JSON Schema validator because of the unknown nested type. + const uncompilableTools: Tool[] = [ + { + name: 'broken-tool', + inputSchema: { type: 'object', properties: {} }, + outputSchema: { + type: 'object', + properties: { value: { type: 'not-a-json-schema-type' } } + } + } + ]; + + const catalogs = [validTools, uncompilableTools]; + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: catalogs.shift() ?? validTools })); + + server.setRequestHandler(CallToolRequestSchema, async () => ({ + structuredContent: { unexpected: 'value' } + })); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Cache the metadata of the first, valid catalog. + await client.listTools(); + + // The refresh fails while compiling the replacement output schema. + await expect(client.listTools()).rejects.toThrow(); + + // The metadata of the last successful catalog must still be in effect. + await expect(client.callTool({ name: 'validated-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + await expect(client.callTool({ name: 'task-only-tool' })).rejects.toThrow(/requires task-based execution/); + }); }); describe('Task-based execution', () => {