From dedf42a8f120a8178bf4c299f83ba6c0ef678799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Karaaslan?= Date: Tue, 13 Oct 2026 18:21:45 +0300 Subject: [PATCH 1/2] feat(client): support async output schema validation --- .changeset/gentle-validators-await.md | 5 + packages/client/src/client/client.ts | 28 +++-- .../jsonSchemaValidatorOverride.test.ts | 118 ++++++++++++++++++ 3 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 .changeset/gentle-validators-await.md 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..3f228dc5d3 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,17 @@ 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 +204,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 +500,7 @@ interface ListenStateEntry { * @internal */ type OutputSchemaCompileResult = - | { ok: true; validator: JsonSchemaValidator } + | { ok: true; validator: ClientOutputValidator } | { ok: false; validator?: undefined; compileError: unknown }; /** @@ -532,7 +542,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 +1850,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 +2395,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 +2470,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..7485436c85 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -114,6 +114,124 @@ 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 From 71ee58cc41cc96036b8fb983db01d7ae20182200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Karaaslan?= Date: Tue, 13 Oct 2026 18:35:39 +0300 Subject: [PATCH 2/2] style(client): format async output validator changes --- packages/client/src/client/client.ts | 3 +-- .../test/client/jsonSchemaValidatorOverride.test.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 3f228dc5d3..f1ce59e887 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -183,8 +183,7 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e * 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 type ClientOutputValidator = (input: unknown) => ReturnType> | Promise>>; export interface ClientJsonSchemaValidator { getValidator(schema: JsonSchemaType): ClientOutputValidator | Promise>; diff --git a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts index 7485436c85..4890741e08 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -163,9 +163,7 @@ describe('client JSON Schema validator overrides', () => { ); 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 expect(client.callTool({ name: 'structured-tool' })).rejects.toThrow(/invalid outputSchema: remote schema compile refused/); await client.close(); await clientTransport.close(); await serverTransport.close(); @@ -179,7 +177,9 @@ describe('client JSON Schema validator overrides', () => { jsonSchemaValidator: { getValidator(_schema: JsonSchemaType) { return async (_value: unknown): Promise> => ({ - valid: false, data: undefined, errorMessage: 'async validation mismatch' + valid: false, + data: undefined, + errorMessage: 'async validation mismatch' }); } } @@ -204,7 +204,9 @@ describe('client JSON Schema validator overrides', () => { 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 + valid: true, + data: value as T, + errorMessage: undefined }); } }