diff --git a/.changeset/gentle-validators-await.md b/.changeset/gentle-validators-await.md new file mode 100644 index 0000000000..5d576aeea0 --- /dev/null +++ b/.changeset/gentle-validators-await.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/client": minor +--- + +Allow asynchronous client tool-output schema compiler and validation callbacks without changing the synchronous shared JSON Schema provider contract. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8118b60cea..f1ce59e887 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -20,7 +20,6 @@ import type { JSONRPCResponse, JsonSchemaType, JsonSchemaValidator, - jsonSchemaValidator, ListChangedHandlers, ListChangedOptions, ListPromptsRequest, @@ -180,6 +179,16 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e return { supportsFormMode, supportsUrlMode }; } +/** + * Client-only JSON Schema validator that can compile and validate asynchronously. + * The shared jsonSchemaValidator/fromJsonSchema contract remains synchronous. + */ +export type ClientOutputValidator = (input: unknown) => ReturnType> | Promise>>; + +export interface ClientJsonSchemaValidator { + getValidator(schema: JsonSchemaType): ClientOutputValidator | Promise>; +} + export type ClientOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this client. @@ -194,7 +203,7 @@ export type ClientOptions = ProtocolOptions & { * * @default Runtime-selected validator (AJV-backed on Node.js, `@cfworker/json-schema`-backed on browser/workerd runtimes) */ - jsonSchemaValidator?: jsonSchemaValidator; + jsonSchemaValidator?: ClientJsonSchemaValidator; /** * Opt-in protocol version negotiation (protocol revision 2026-07-28 and later). @@ -490,7 +499,7 @@ interface ListenStateEntry { * @internal */ type OutputSchemaCompileResult = - | { ok: true; validator: JsonSchemaValidator } + | { ok: true; validator: ClientOutputValidator } | { ok: false; validator?: undefined; compileError: unknown }; /** @@ -532,7 +541,7 @@ export class Client extends Protocol { private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; - private _jsonSchemaValidator: jsonSchemaValidator; + private _jsonSchemaValidator: ClientJsonSchemaValidator; /** * The response-cache substrate. Owns the backing store, the per-method * eviction-generation counter, the user-supplied/default flag, and the @@ -1840,11 +1849,13 @@ export class Client extends Protocol { * `compileError !== undefined`) means a custom provider that does * `throw undefined` is still treated as a captured failure. */ - private _compileOutputValidator(tool: Tool): OutputSchemaCompileResult | undefined { + private async _compileOutputValidator(tool: Tool): Promise { if (!tool.outputSchema) return undefined; try { - return { ok: true, validator: this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType) }; + const validator = await this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + return { ok: true, validator }; } catch (error) { + // Captures both synchronous throws and asynchronous compilation rejections. return { ok: false, compileError: error }; } } @@ -2383,7 +2394,7 @@ export class Client extends Protocol { ? await this._cache .outputValidator(params.name, tool => this._compileOutputValidator(tool)) .catch(error => void this._reportStoreError(error)) - : this._compileOutputValidator(options.toolDefinition); + : await this._compileOutputValidator(options.toolDefinition); const assertCompiled = (): void => { if (compiled === undefined || compiled.ok) return; const err = compiled.compileError; @@ -2458,7 +2469,7 @@ export class Client extends Protocol { if (result.structuredContent !== undefined && !result.isError) { try { // Validate the structured content against the schema - const validationResult = validator(result.structuredContent); + const validationResult = await validator(result.structuredContent); if (!validationResult.valid) { throw new ProtocolError( diff --git a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts index ecd3b5d5ca..4890741e08 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -114,6 +114,126 @@ describe('client JSON Schema validator overrides', () => { await serverTransport.close(); }); + test('awaits asynchronous compiler and validator while keeping sync providers supported', async () => { + const compiledSchemas: JsonSchemaType[] = []; + const observedValues: unknown[] = []; + const client = new Client( + { name: 'async-validation-client', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: { + async getValidator(schema: JsonSchemaType) { + compiledSchemas.push(schema); + await Promise.resolve(); + return async (value: unknown): Promise> => { + await Promise.resolve(); + observedValues.push(value); + return { valid: true, data: value as T, errorMessage: undefined }; + }; + } + } + } + ); + const { clientTransport, serverTransport } = await connectInitializedClient(client); + await client.listTools(); + await expect(client.callTool({ name: 'structured-tool' })).resolves.toMatchObject({ + structuredContent: { count: 42 } + }); + await expect(client.callTool({ name: 'structured-tool' })).resolves.toMatchObject({ + structuredContent: { count: 42 } + }); + expect(compiledSchemas).toHaveLength(1); + expect(observedValues).toEqual([{ count: 42 }, { count: 42 }]); + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); + + test('rejects async compilation failure before sending a tools/call request', async () => { + const client = new Client( + { name: 'async-failure-client', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: { + getValidator(_schema: JsonSchemaType): Promise<(input: unknown) => JsonSchemaValidatorResult> { + return Promise.reject(new Error('remote schema compile refused')); + } + } + } + ); + const { clientTransport, serverTransport } = await connectInitializedClient(client); + await client.listTools(); + await expect(client.callTool({ name: 'structured-tool' })).rejects.toThrow(/invalid outputSchema: remote schema compile refused/); + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); + + test('reports asynchronous output-validation mismatch as InvalidParams', async () => { + const client = new Client( + { name: 'async-mismatch-client', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: { + getValidator(_schema: JsonSchemaType) { + return async (_value: unknown): Promise> => ({ + valid: false, + data: undefined, + errorMessage: 'async validation mismatch' + }); + } + } + } + ); + const { clientTransport, serverTransport } = await connectInitializedClient(client); + await client.listTools(); + await expect(client.callTool({ name: 'structured-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema: async validation mismatch/ + ); + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); + + test('async compiler failure in explicit toolDefinition does not poison the listed schema', async () => { + const client = new Client( + { name: 'async-isolation-client', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: { + async getValidator(schema: JsonSchemaType) { + if (schema.title === 'reject-me') throw new Error('isolated compilation failure'); + return async (value: unknown): Promise> => ({ + valid: true, + data: value as T, + errorMessage: undefined + }); + } + } + } + ); + const { clientTransport, serverTransport } = await connectInitializedClient(client); + await client.listTools(); + await expect( + client.callTool( + { name: 'structured-tool' }, + { + toolDefinition: { + name: 'structured-tool', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object', title: 'reject-me' } + } + } + ) + ).rejects.toThrow(/isolated compilation failure/); + await expect(client.callTool({ name: 'structured-tool' })).resolves.toMatchObject({ + structuredContent: { count: 42 } + }); + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); + describe('outputSchema compile-error lifecycle (substrate-held; no parallel map)', () => { // SEP-2106 §invalid-outputSchema: a tool whose outputSchema fails to compile is // surfaced as a typed InvalidParams BEFORE the request is sent. The compile error is