feat(client,server): bundle default validators, expose customisation via subpaths (#2088)

This commit is contained in:
Matt 2026-05-29 19:39:32 +01:00 • committed by GitHub
commit 16d13abf78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 749 additions and 314 deletions

View file

@ -28,6 +28,10 @@
"types": "./dist/stdio.d.mts",
"import": "./dist/stdio.mjs"
},
"./validators/ajv": {
"types": "./dist/validators/ajv.d.mts",
"import": "./dist/validators/ajv.mjs"
},
"./validators/cf-worker": {
"types": "./dist/validators/cfWorker.d.mts",
"import": "./dist/validators/cfWorker.mjs"
@ -54,6 +58,9 @@
"types": "./dist/index.d.mts",
"typesVersions": {
"*": {
"validators/ajv": [
"dist/validators/ajv.d.mts"
],
"validators/cf-worker": [
"dist/validators/cfWorker.d.mts"
],
@ -93,6 +100,8 @@
"@modelcontextprotocol/eslint-config": "workspace:^",
"@modelcontextprotocol/test-helpers": "workspace:^",
"@cfworker/json-schema": "catalog:runtimeShared",
"ajv": "catalog:runtimeShared",
"ajv-formats": "catalog:runtimeShared",
"@types/content-type": "catalog:devTools",
"@types/cross-spawn": "catalog:devTools",
"@types/eventsource": "catalog:devTools",

View file

@ -161,7 +161,7 @@ export type ClientOptions = ProtocolOptions & {
* The validator is used to validate structured content returned by tools
* against their declared output schemas.
*
* @default {@linkcode DefaultJsonSchemaValidator} ({@linkcode index.AjvJsonSchemaValidator | AjvJsonSchemaValidator} on Node.js, `CfWorkerJsonSchemaValidator` on Cloudflare Workers)
* @default Runtime-selected validator (AJV-backed on Node.js, `@cfworker/json-schema`-backed on browser/workerd runtimes)
*/
jsonSchemaValidator?: jsonSchemaValidator;

View file

@ -3,7 +3,7 @@
*
* This file is selected via package.json export conditions when running in Node.js.
*/
export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core';
export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';
/**
* Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept —

View file

@ -0,0 +1,14 @@
/**
* Customisation entry point for the AJV validator. Re-exports `Ajv` + `addFormats` from the
* SDK's bundled copy, so customising the validator needs no extra installs.
*
* @example
* ```ts
* import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv';
*
* const ajv = new Ajv({ strict: true, allErrors: true });
* addFormats(ajv);
* const validator = new AjvJsonSchemaValidator(ajv);
* ```
*/
export { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';

View file

@ -1,10 +1,3 @@
/**
* Cloudflare Workers JSON Schema validator, available as a sub-path export.
*
* @example
* ```ts
* import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/client/validators/cf-worker';
* ```
*/
/** Customisation entry point for the `@cfworker/json-schema` validator. */
export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core/validators/cfWorker';
export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker';

View file

@ -8,6 +8,8 @@ import { beforeAll, describe, expect, test } from 'vitest';
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '../..');
const distDir = join(pkgDir, 'dist');
const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\b/;
// Anchored at start-of-line so JSDoc-example `from 'ajv'` strings in vendored chunks don't match.
const VALIDATOR_BACKEND_IMPORT = /^import[^\n]*?from\s+["'](?:ajv|ajv-formats|@cfworker\/json-schema)["']/m;
function chunkImportsOf(entryPath: string): string[] {
const visited = new Set<string>();
@ -52,4 +54,17 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => {
expect(stdio).toMatch(/\bgetDefaultEnvironment\b/);
expect(stdio).toMatch(/\bDEFAULT_INHERITED_ENV_VARS\b/);
});
test('runtime shims vendor default validator backends instead of requiring consumers to install them', () => {
for (const shim of ['shimsNode.mjs', 'shimsWorkerd.mjs', 'shimsBrowser.mjs']) {
const entry = join(distDir, shim);
expect(readFileSync(entry, 'utf8')).not.toMatch(VALIDATOR_BACKEND_IMPORT);
for (const chunk of chunkImportsOf(entry)) {
expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual(
expect.objectContaining({ content: expect.stringMatching(VALIDATOR_BACKEND_IMPORT) })
);
}
}
});
});

View file

@ -0,0 +1,110 @@
import type { JSONRPCMessage, JsonSchemaType, JsonSchemaValidatorResult, jsonSchemaValidator } from '@modelcontextprotocol/core';
import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
import { Client } from '../../src/client/client.js';
import { fromJsonSchema } from '../../src/fromJsonSchema.js';
class RecordingValidator implements jsonSchemaValidator {
schemas: JsonSchemaType[] = [];
values: unknown[] = [];
getValidator<T>(schema: JsonSchemaType) {
this.schemas.push(schema);
return (value: unknown): JsonSchemaValidatorResult<T> => {
this.values.push(value);
return { valid: true, data: value as T, errorMessage: undefined };
};
}
}
async function connectInitializedClient(client: Client) {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
serverTransport.onmessage = async message => {
if ('method' in message && 'id' in message && message.method === 'initialize') {
await serverTransport.send({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: 'test-server', version: '1.0.0' }
}
});
} else if ('method' in message && 'id' in message && message.method === 'tools/list') {
await serverTransport.send({
jsonrpc: '2.0',
id: message.id,
result: {
tools: [
{
name: 'structured-tool',
description: 'A tool with structured output',
inputSchema: { type: 'object' },
outputSchema: {
type: 'object',
properties: { count: { type: 'number' } },
required: ['count']
}
}
]
}
} satisfies JSONRPCMessage);
}
};
await Promise.all([client.connect(clientTransport), serverTransport.start()]);
return { clientTransport, serverTransport };
}
describe('client JSON Schema validator overrides', () => {
test('Client constructor uses a custom validator for tool output schema caching', async () => {
const validator = new RecordingValidator();
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{
capabilities: {},
jsonSchemaValidator: validator
}
);
const { clientTransport, serverTransport } = await connectInitializedClient(client);
await expect(client.listTools()).resolves.toMatchObject({
tools: [
{
name: 'structured-tool',
outputSchema: {
type: 'object',
properties: { count: { type: 'number' } },
required: ['count']
}
}
]
});
expect(validator.schemas).toEqual([
{
type: 'object',
properties: { count: { type: 'number' } },
required: ['count']
}
]);
await client.close();
await clientTransport.close();
await serverTransport.close();
});
test('fromJsonSchema uses an explicitly supplied custom validator', async () => {
const validator = new RecordingValidator();
const schema: JsonSchemaType = {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
};
const standardSchema = fromJsonSchema<{ name: string }>(schema, validator);
expect(standardSchema['~standard'].validate({ name: 123 })).toEqual({ value: { name: 123 } });
expect(validator.schemas).toEqual([schema]);
expect(validator.values).toEqual([{ name: 123 }]);
});
});

View file

@ -7,6 +7,7 @@
"*": ["./*"],
"@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"],
"@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"],
"@modelcontextprotocol/core/validators/ajv": ["./node_modules/@modelcontextprotocol/core/src/validators/ajvProvider.ts"],
"@modelcontextprotocol/core/validators/cfWorker": [
"./node_modules/@modelcontextprotocol/core/src/validators/cfWorkerProvider.ts"
],

View file

@ -2,39 +2,35 @@ import { defineConfig } from 'tsdown';
export default defineConfig({
failOnWarn: 'ci-only',
// 1. Entry Points
// Directly matches package.json include/exclude globs
entry: ['src/index.ts', 'src/stdio.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', 'src/validators/cfWorker.ts'],
// 2. Output Configuration
entry: [
'src/index.ts',
'src/stdio.ts',
'src/shimsNode.ts',
'src/shimsWorkerd.ts',
'src/shimsBrowser.ts',
'src/validators/ajv.ts',
'src/validators/cfWorker.ts'
],
format: ['esm'],
outDir: 'dist',
clean: true, // Recommended: Cleans 'dist' before building
clean: true,
sourcemap: true,
// 3. Platform & Target
target: 'esnext',
platform: 'node',
shims: true, // Polyfills common Node.js shims (__dirname, etc.)
// 4. Type Definitions
// Bundles d.ts files into a single output
shims: true,
dts: {
resolver: 'tsc',
// override just for DTS generation:
resolve: ['ajv', 'ajv-formats'],
compilerOptions: {
baseUrl: '.',
paths: {
'@modelcontextprotocol/core': ['../core/src/index.ts'],
'@modelcontextprotocol/core/public': ['../core/src/exports/public/index.ts'],
'@modelcontextprotocol/core/validators/ajv': ['../core/src/validators/ajvProvider.ts'],
'@modelcontextprotocol/core/validators/cfWorker': ['../core/src/validators/cfWorkerProvider.ts']
}
}
},
// 5. Vendoring Strategy - Bundle the code for this specific package into the output,
// but treat all other dependencies as external (require/import).
noExternal: ['@modelcontextprotocol/core'],
// 6. External packages - keep self-reference imports external for runtime resolution
noExternal: ['@modelcontextprotocol/core', 'ajv', 'ajv-formats', '@cfworker/json-schema'],
external: ['@modelcontextprotocol/client/_shims']
});