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
7 changed files with 749 additions and 314 deletions

View file

@ -22,7 +22,6 @@
import { Client } from '@modelcontextprotocol/client';
import type { JsonSchemaType } from '@modelcontextprotocol/core';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/core';
import type {
CreateMessageRequest,
CreateMessageResult,
@ -33,6 +32,7 @@ import type {
Tool
} from '@modelcontextprotocol/server';
import { McpServer, ProtocolError, ProtocolErrorCode, Server, UrlElicitationRequiredError } from '@modelcontextprotocol/server';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';

View file

@ -14,8 +14,6 @@
import { Client } from '@modelcontextprotocol/client';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, StandardSchemaWithJSON } from '@modelcontextprotocol/core';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/core';
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker';
import type { Tool } from '@modelcontextprotocol/server';
import {
fromJsonSchema,
@ -26,6 +24,8 @@ import {
Server,
specTypeSchemas
} from '@modelcontextprotocol/server';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
import { expect } from 'vitest';
import { z } from 'zod/v4';

View file

@ -13,9 +13,13 @@
"@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"],
"@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"],
"@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"],
"@modelcontextprotocol/client/validators/ajv": ["./node_modules/@modelcontextprotocol/client/src/validators/ajv.ts"],
"@modelcontextprotocol/client/validators/cf-worker": ["./node_modules/@modelcontextprotocol/client/src/validators/cfWorker.ts"],
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
"@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"],
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
"@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"],
"@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"],
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
"@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"],
"@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"],

View file

@ -15,15 +15,77 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const PORT = 8787;
const READINESS_TIMEOUT_MS = 60_000;
const READINESS_POLL_INTERVAL_MS = 100;
interface TestEnv {
tempDir: string;
process: ChildProcess;
cleanup: () => Promise<void>;
/**
* Wait until the worker can serve a real MCP `initialize` request.
*
* Wrangler's "Ready on …" stdout line is unreliable: miniflare can print it before the user
* worker is actually wired, and subsequent POSTs come back as `500 Network connection lost` or
* `ECONNREFUSED`. The only signal we can trust is "the server returned an MCP-shaped response
* to a protocol request".
*
* Polls the configured port with an MCP `initialize` POST every {@link READINESS_POLL_INTERVAL_MS}ms
* until either a JSON-RPC result body comes back, the wrangler process exits, or
* {@link READINESS_TIMEOUT_MS} elapses.
*/
async function waitForMcpReady(proc: ChildProcess): Promise<void> {
let stderrTail = '';
proc.stderr?.on('data', d => {
stderrTail = (stderrTail + d.toString()).slice(-2048);
});
let processExitedWithCode: number | null = null;
proc.on('exit', code => {
processExitedWithCode = code ?? -1;
});
const deadline = Date.now() + READINESS_TIMEOUT_MS;
let lastFailure = 'no attempts made';
while (Date.now() < deadline) {
if (processExitedWithCode !== null) {
throw new Error(`wrangler dev exited with code ${processExitedWithCode} before becoming ready.\nstderr tail:\n${stderrTail}`);
}
try {
const response = await fetch(`http://127.0.0.1:${PORT}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 'readiness-probe',
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'readiness-probe', version: '0' }
}
})
});
const body = await response.text();
if (response.ok && body.includes('"jsonrpc"') && body.includes('"result"')) {
return;
}
lastFailure = `status=${response.status} body=${body.slice(0, 200)}`;
} catch (error) {
lastFailure = (error as { cause?: { code?: string }; message: string }).cause?.code ?? (error as Error).message;
}
await new Promise(resolve => setTimeout(resolve, READINESS_POLL_INTERVAL_MS));
}
throw new Error(
`Worker did not become ready within ${READINESS_TIMEOUT_MS}ms.\nLast probe: ${lastFailure}\nstderr tail:\n${stderrTail}`
);
}
describe('Cloudflare Workers compatibility (no nodejs_compat)', () => {
let env: TestEnv | null = null;
let cleanup: (() => Promise<void>) | null = null;
beforeAll(async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-worker-test-'));
@ -42,8 +104,7 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => {
private: true,
type: 'module',
dependencies: {
'@modelcontextprotocol/server': `file:./${tarballName}`,
'@cfworker/json-schema': '^4.1.1'
'@modelcontextprotocol/server': `file:./${tarballName}`
},
devDependencies: {
wrangler: '^4.14.4'
@ -84,50 +145,22 @@ export default {
// Install dependencies
execSync('npm install', { cwd: tempDir, stdio: 'pipe', timeout: 60_000 });
// Start wrangler dev server
// Start wrangler dev server. Readiness is determined by probing the MCP endpoint, not by
// parsing wrangler's stdout — see waitForMcpReady for the reasoning.
const proc = spawn('npx', ['wrangler', 'dev', '--local', '--port', String(PORT)], {
cwd: tempDir,
shell: true,
stdio: 'pipe'
});
// Wait for server to be ready
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Wrangler startup timeout')), 60_000);
let stderrData = '';
try {
await waitForMcpReady(proc);
} catch (error) {
proc.kill('SIGTERM');
throw error;
}
proc.stdout?.on('data', data => {
const output = data.toString();
if (/Ready on|Listening on/.test(output)) {
clearTimeout(timeout);
// Extra delay for wrangler to fully initialize
setTimeout(resolve, 1000);
}
});
proc.stderr?.on('data', data => {
stderrData += data.toString();
// Check for fatal errors like missing node: modules
if (/No such module "node:/.test(stderrData)) {
clearTimeout(timeout);
reject(new Error(`Wrangler fatal error: ${stderrData}`));
}
});
proc.on('error', err => {
clearTimeout(timeout);
reject(err);
});
proc.on('close', code => {
if (code !== 0 && code !== null) {
clearTimeout(timeout);
reject(new Error(`Wrangler exited with code ${code}. stderr: ${stderrData}`));
}
});
});
const cleanup = async () => {
cleanup = async () => {
proc.kill('SIGTERM');
await new Promise<void>(resolve => {
proc.on('close', () => resolve());
@ -139,35 +172,16 @@ export default {
// Ignore cleanup errors
}
};
env = { tempDir, process: proc, cleanup };
}, 120_000);
afterAll(async () => {
await env?.cleanup();
await cleanup?.();
});
it('should handle MCP requests', async () => {
expect(env).not.toBeNull();
// Retry connection — wrangler may report "Ready" before it can handle requests
let client!: Client;
let lastError: unknown;
for (let attempt = 0; attempt < 5; attempt++) {
try {
client = new Client({ name: 'test-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/`));
await client.connect(transport);
lastError = undefined;
break;
} catch (error) {
lastError = error;
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
if (lastError) {
throw lastError;
}
const client = new Client({ name: 'test-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/`));
await client.connect(transport);
const result = await client.callTool({ name: 'greet', arguments: { name: 'World' } });
expect(result.content).toEqual([{ type: 'text', text: 'Hello, World!' }]);

View file

@ -9,7 +9,8 @@
import { Client } from '@modelcontextprotocol/client';
import type { ElicitRequestFormParams } from '@modelcontextprotocol/core';
import { AjvJsonSchemaValidator, InMemoryTransport } from '@modelcontextprotocol/core';
import { InMemoryTransport } from '@modelcontextprotocol/core';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker';
import { Server } from '@modelcontextprotocol/server';

View file

@ -5,7 +5,7 @@
import { Client } from '@modelcontextprotocol/client';
import type { TextContent } from '@modelcontextprotocol/core';
import { AjvJsonSchemaValidator, fromJsonSchema, InMemoryTransport } from '@modelcontextprotocol/core';
import { InMemoryTransport } from '@modelcontextprotocol/core';
import { completable, fromJsonSchema as serverFromJsonSchema, McpServer } from '@modelcontextprotocol/server';
import { toStandardJsonSchema } from '@valibot/to-json-schema';
import { type } from 'arktype';
@ -382,13 +382,12 @@ describe('Standard Schema Support', () => {
});
describe('Raw JSON Schema via fromJsonSchema', () => {
const validator = new AjvJsonSchemaValidator();
test('should register tool with raw JSON Schema input', async () => {
const inputSchema = fromJsonSchema<{ name: string }>(
{ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
validator
);
const inputSchema = serverFromJsonSchema<{ name: string }>({
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
});
mcpServer.registerTool('greet', { inputSchema }, async ({ name }) => ({
content: [{ type: 'text', text: `Hello, ${name}!` }]
@ -407,11 +406,12 @@ describe('Standard Schema Support', () => {
expect((result.content[0] as TextContent).text).toBe('Hello, World!');
});
test('should reject invalid input via AJV validation', async () => {
const inputSchema = fromJsonSchema(
{ type: 'object', properties: { count: { type: 'number' } }, required: ['count'] },
validator
);
test('should reject invalid input via default validation', async () => {
const inputSchema = serverFromJsonSchema({
type: 'object',
properties: { count: { type: 'number' } },
required: ['count']
});
mcpServer.registerTool('double', { inputSchema }, async args => {
const { count } = args as { count: number };
@ -428,44 +428,6 @@ describe('Standard Schema Support', () => {
});
});
describe('fromJsonSchema with default validator (server wrapper)', () => {
test('should use runtime-appropriate default validator when none is provided', async () => {
const inputSchema = serverFromJsonSchema<{ name: string }>({
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
});
mcpServer.registerTool('greet-default', { inputSchema }, async ({ name }) => ({
content: [{ type: 'text', text: `Hello, ${name}!` }]
}));
await connectClientAndServer();
const result = await client.request({ method: 'tools/call', params: { name: 'greet-default', arguments: { name: 'World' } } });
expect((result.content[0] as TextContent).text).toBe('Hello, World!');
});
test('should reject invalid input with default validator', async () => {
const inputSchema = serverFromJsonSchema({ type: 'object', properties: { count: { type: 'number' } }, required: ['count'] });
mcpServer.registerTool('double-default', { inputSchema }, async args => {
const { count } = args as { count: number };
return { content: [{ type: 'text', text: `${count * 2}` }] };
});
await connectClientAndServer();
const result = await client.request({
method: 'tools/call',
params: { name: 'double-default', arguments: { count: 'not a number' } }
});
expect(result.isError).toBe(true);
const errorText = (result.content[0] as TextContent).text;
expect(errorText).toContain('Input validation error');
});
});
describe('Prompt completions with Zod completable', () => {
// Note: completable() is currently Zod-specific
// These tests verify that Zod schemas with completable still work

View file

@ -7,15 +7,20 @@
"*": ["./*"],
"@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"
],
"@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"],
"@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"],
"@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"],
"@modelcontextprotocol/client/validators/ajv": ["./node_modules/@modelcontextprotocol/client/src/validators/ajv.ts"],
"@modelcontextprotocol/client/validators/cf-worker": ["./node_modules/@modelcontextprotocol/client/src/validators/cfWorker.ts"],
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
"@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"],
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
"@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"],
"@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"],
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
"@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"],
"@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"],