Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonSchemaValidator<unknown>>();
const knownTaskTools = new Set<string>();
const requiredTaskTools = new Set<string>();

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;
}

/**
Expand Down
85 changes: 85 additions & 0 deletions test/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading