feat(client,server): bundle default validators, expose customisation via subpaths (#2088)
This commit is contained in:
parent
4a5c863a21
commit
16d13abf78
9 changed files with 749 additions and 314 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
14
packages/client/src/validators/ajv.ts
Normal file
14
packages/client/src/validators/ajv.ts
Normal 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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
110
packages/client/test/client/jsonSchemaValidatorOverride.test.ts
Normal file
110
packages/client/test/client/jsonSchemaValidatorOverride.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue