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
5 changes: 5 additions & 0 deletions .changeset/gentle-validators-await.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 19 additions & 8 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import type {
JSONRPCResponse,
JsonSchemaType,
JsonSchemaValidator,
jsonSchemaValidator,
ListChangedHandlers,
ListChangedOptions,
ListPromptsRequest,
Expand Down Expand Up @@ -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<T> = (input: unknown) => ReturnType<JsonSchemaValidator<T>> | Promise<ReturnType<JsonSchemaValidator<T>>>;

export interface ClientJsonSchemaValidator {
getValidator<T>(schema: JsonSchemaType): ClientOutputValidator<T> | Promise<ClientOutputValidator<T>>;
}

export type ClientOptions = ProtocolOptions & {
/**
* Capabilities to advertise as being supported by this client.
Expand All @@ -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).
Expand Down Expand Up @@ -490,7 +499,7 @@ interface ListenStateEntry {
* @internal
*/
type OutputSchemaCompileResult =
| { ok: true; validator: JsonSchemaValidator<unknown> }
| { ok: true; validator: ClientOutputValidator<unknown> }
| { ok: false; validator?: undefined; compileError: unknown };

/**
Expand Down Expand Up @@ -532,7 +541,7 @@ export class Client extends Protocol<ClientContext> {
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
Expand Down Expand Up @@ -1840,11 +1849,13 @@ export class Client extends Protocol<ClientContext> {
* `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<OutputSchemaCompileResult | undefined> {
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 };
}
}
Expand Down Expand Up @@ -2383,7 +2394,7 @@ export class Client extends Protocol<ClientContext> {
? 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;
Expand Down Expand Up @@ -2458,7 +2469,7 @@ export class Client extends Protocol<ClientContext> {
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(
Expand Down
120 changes: 120 additions & 0 deletions packages/client/test/client/jsonSchemaValidatorOverride.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(schema: JsonSchemaType) {
compiledSchemas.push(schema);
await Promise.resolve();
return async (value: unknown): Promise<JsonSchemaValidatorResult<T>> => {
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<T>(_schema: JsonSchemaType): Promise<(input: unknown) => JsonSchemaValidatorResult<T>> {
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<T>(_schema: JsonSchemaType) {
return async (_value: unknown): Promise<JsonSchemaValidatorResult<T>> => ({
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<T>(schema: JsonSchemaType) {
if (schema.title === 'reject-me') throw new Error('isolated compilation failure');
return async (value: unknown): Promise<JsonSchemaValidatorResult<T>> => ({
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
Expand Down
Loading