feat(client,server): bundle default validators, expose customisation via subpaths (#2088)
This commit is contained in:
parent
4a5c863a21
commit
16d13abf78
7 changed files with 749 additions and 314 deletions
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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!' }]);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue