Skip to content

Commit f0130bf

Browse files
committed
fix(client): isolate per-tool outputSchema compile failures (PR #2249 review)
Addresses claude-bot review on PR #2249: assertSchemaSafeToCompile (SEP-2106 SSRF / composition-DoS guard) throws inside getValidator(), and cacheToolMetadata() eagerly compiled every advertised tool's outputSchema with no per-tool error handling. A single tool advertising a non-local $ref or an over-budget schema therefore rejected the entire listTools() call, leaving the client unable to list or call ANY tool from that server. - catch compilation per-tool in cacheToolMetadata(); store the error in a new _toolOutputValidatorErrors map instead of letting it propagate. - surface the scoped error from callTool() only when the offending tool is actually called (clear, descriptive ProtocolError). - integration test: one tool with a non-local $ref outputSchema does not break listTools() or the use of a sibling good tool; the bad tool errors only on call. Note: the second review comment (experimental/tasks callToolStream truthiness) is stale \u2014 the tasks feature was removed in c8d7401 before this branch, no callToolStream exists, and no truthiness structuredContent checks remain.
1 parent 19793ee commit f0130bf

2 files changed

Lines changed: 82 additions & 3 deletions

File tree

packages/client/src/client/client.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,13 @@ export class Client extends Protocol<ClientContext> {
218218
private _instructions?: string;
219219
private _jsonSchemaValidator: jsonSchemaValidator;
220220
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = new Map();
221+
/**
222+
* Tools whose advertised `outputSchema` could not be compiled into a validator (e.g. it tripped
223+
* the SEP-2106 safety guards — a non-local `$ref` or an over-budget schema). The error is stored
224+
* per-tool and surfaced only when that tool is called, so one malformed tool definition does not
225+
* break `listTools()` or the use of every other tool from the same server.
226+
*/
227+
private _toolOutputValidatorErrors: Map<string, Error> = new Map();
221228
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
222229
private _pendingListChangedConfig?: ListChangedHandlers;
223230
private _enforceStrictCapabilities: boolean;
@@ -790,6 +797,17 @@ export class Client extends Protocol<ClientContext> {
790797
async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise<CallToolResult> {
791798
const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options);
792799

800+
// If the tool advertised an outputSchema that failed to compile (e.g. a SEP-2106 safety-guard
801+
// rejection), surface that error now — scoped to this tool — rather than silently skipping
802+
// output validation.
803+
const validatorError = this._toolOutputValidatorErrors.get(params.name);
804+
if (validatorError) {
805+
throw new ProtocolError(
806+
ProtocolErrorCode.InvalidParams,
807+
`Tool ${params.name} has an output schema that could not be compiled: ${validatorError.message}`
808+
);
809+
}
810+
793811
// Check if the tool has an outputSchema
794812
const validator = this.getToolOutputValidator(params.name);
795813
if (validator) {
@@ -836,12 +854,19 @@ export class Client extends Protocol<ClientContext> {
836854
*/
837855
private cacheToolMetadata(tools: Tool[]): void {
838856
this._cachedToolOutputValidators.clear();
857+
this._toolOutputValidatorErrors.clear();
839858

840859
for (const tool of tools) {
841-
// If the tool has an outputSchema, create and cache the validator
860+
// If the tool has an outputSchema, create and cache the validator. Compilation can throw
861+
// (invalid schema, or a SEP-2106 safety-guard rejection); scope that failure to the
862+
// offending tool rather than letting it reject the whole listTools() call.
842863
if (tool.outputSchema) {
843-
const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType);
844-
this._cachedToolOutputValidators.set(tool.name, toolValidator);
864+
try {
865+
const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType);
866+
this._cachedToolOutputValidators.set(tool.name, toolValidator);
867+
} catch (error) {
868+
this._toolOutputValidatorErrors.set(tool.name, error instanceof Error ? error : new Error(String(error)));
869+
}
845870
}
846871
}
847872
}

test/integration/test/client/client.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1940,6 +1940,60 @@ describe('outputSchema validation', () => {
19401940
);
19411941
});
19421942

1943+
/***
1944+
* Test: A single tool with an uncompilable outputSchema does not break listTools()
1945+
* or the use of other tools (SEP-2106 safety-guard isolation).
1946+
*/
1947+
test('isolates a tool whose outputSchema fails to compile from the rest of the server', async () => {
1948+
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } });
1949+
1950+
server.setRequestHandler('initialize', async request => ({
1951+
protocolVersion: request.params.protocolVersion,
1952+
capabilities: { tools: {} },
1953+
serverInfo: { name: 'test-server', version: '1.0.0' }
1954+
}));
1955+
1956+
server.setRequestHandler('tools/list', async () => ({
1957+
tools: [
1958+
{
1959+
name: 'bad-tool',
1960+
description: 'advertises a non-local $ref the SEP-2106 guard rejects',
1961+
inputSchema: { type: 'object', properties: {} },
1962+
// Non-same-document $ref: assertSchemaSafeToCompile throws when this is compiled.
1963+
outputSchema: { $ref: 'https://evil.example/schema.json' }
1964+
},
1965+
{
1966+
name: 'good-tool',
1967+
description: 'a normal tool that must remain usable',
1968+
inputSchema: { type: 'object', properties: {} },
1969+
outputSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] }
1970+
}
1971+
]
1972+
}));
1973+
1974+
server.setRequestHandler('tools/call', async request => {
1975+
if (request.params.name === 'good-tool') {
1976+
return { content: [], structuredContent: { ok: true } };
1977+
}
1978+
return { content: [], structuredContent: { irrelevant: true } };
1979+
});
1980+
1981+
const client = new Client({ name: 'test-client', version: '1.0.0' });
1982+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1983+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
1984+
1985+
// listTools() must NOT reject just because one tool's schema is uncompilable.
1986+
const listed = await client.listTools();
1987+
expect(listed.tools.map(t => t.name).toSorted()).toEqual(['bad-tool', 'good-tool']);
1988+
1989+
// The good tool is fully usable.
1990+
const good = await client.callTool({ name: 'good-tool' });
1991+
expect(good.structuredContent).toEqual({ ok: true });
1992+
1993+
// The bad tool surfaces a scoped, descriptive error only when it is called.
1994+
await expect(client.callTool({ name: 'bad-tool' })).rejects.toThrow(/output schema that could not be compiled/i);
1995+
});
1996+
19431997
/***
19441998
* Test: Handle Tools Without outputSchema Normally
19451999
*/

0 commit comments

Comments
 (0)