fix(validators): honor declared draft-07/06 JSON Schema dialects instead of rejecting them (#2534)
This commit is contained in:
parent
6dac272880
commit
f130e1ac5f
3 changed files with 851 additions and 109 deletions
|
|
@ -516,7 +516,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
|
|||
'client:jsonschema:unsupported-dialect-graceful': {
|
||||
source: 'sdk',
|
||||
behavior:
|
||||
'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect … 2020-12 only" message instead of having the underlying engine fail opaquely.'
|
||||
'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise (2020-12, 2019-09, draft-07, and draft-06 are supported) is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect" message instead of having the underlying engine fail opaquely.'
|
||||
},
|
||||
'client:jsonschema:bad-schema-isolates-tool': {
|
||||
source: 'sdk',
|
||||
|
|
|
|||
164
test/integration/test/client/outputSchemaDialect.test.ts
Normal file
164
test/integration/test/client/outputSchemaDialect.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Ecosystem servers advertise tool schemas stamped with a draft-07 `$schema`
|
||||
* (zod-to-json-schema's default output — e.g. the official Filesystem server).
|
||||
* The spec honors the declared dialect (absent means 2020-12), so the default
|
||||
* validator must dispatch on it instead of rejecting pre-wire with InvalidParams.
|
||||
* Unknown dialects still produce the typed error.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { JsonSchemaType } from '@modelcontextprotocol/core-internal';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
|
||||
import { fromJsonSchema, McpServer, Server } from '@modelcontextprotocol/server';
|
||||
|
||||
/** zod-to-json-schema default output shape, lifted from the official Filesystem server. */
|
||||
const FILESYSTEM_STYLE_SCHEMA = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: { type: 'string' },
|
||||
encoding: { type: 'string', enum: ['utf8', 'base64'] }
|
||||
},
|
||||
required: ['content'],
|
||||
additionalProperties: false
|
||||
} as const;
|
||||
|
||||
/** Draft-07 tuple form: positional `items` array (2020-12 moved this to `prefixItems`). */
|
||||
const DRAFT_07_TUPLE_SCHEMA = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
pair: { type: 'array', items: [{ type: 'number' }, { type: 'string' }] }
|
||||
},
|
||||
required: ['pair']
|
||||
} as const;
|
||||
|
||||
/** zod-to-json-schema `target: 'openAi'` output shape (also its `target: '2019-09'`). */
|
||||
const OPENAI_TARGET_SCHEMA = {
|
||||
$schema: 'https://json-schema.org/draft/2019-09/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
score: { type: 'number' }
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A real low-level Server advertising a verbatim outputSchema without compiling it —
|
||||
* the shape of a non-SDK ecosystem server. `structuredContent` comes from `results`
|
||||
* keyed by tool name.
|
||||
*/
|
||||
async function connectPair(
|
||||
outputSchema: unknown,
|
||||
structuredContent: () => unknown
|
||||
): Promise<{ client: Client; close: () => Promise<unknown> }> {
|
||||
const server = new Server({ name: 'ecosystem-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [{ name: 'read_text_file', inputSchema: { type: 'object' }, outputSchema: outputSchema as JsonSchemaType }]
|
||||
}));
|
||||
server.setRequestHandler('tools/call', async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
structuredContent: structuredContent() as Record<string, unknown>
|
||||
}));
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
// Populate the tools/list cache — output validation derives from the cached entry.
|
||||
await client.listTools();
|
||||
return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) };
|
||||
}
|
||||
|
||||
describe('declared-dialect tool schemas end to end', () => {
|
||||
test('draft-07 outputSchema (Filesystem-server shape) validates instead of failing pre-wire', async () => {
|
||||
const { client, close } = await connectPair(FILESYSTEM_STYLE_SCHEMA, () => ({ content: 'hello', encoding: 'utf8' }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({
|
||||
structuredContent: { content: 'hello', encoding: 'utf8' }
|
||||
});
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('a VIOLATING result against a draft-07 outputSchema still fails validation', async () => {
|
||||
// `content` missing — the draft-07 engine must actually run, not pass through.
|
||||
const { client, close } = await connectPair(FILESYSTEM_STYLE_SCHEMA, () => ({ encoding: 'utf8' }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('draft-07 tuple `items` gets draft-07 positional semantics', async () => {
|
||||
let pair: unknown = [1, 'x'];
|
||||
const { client, close } = await connectPair(DRAFT_07_TUPLE_SCHEMA, () => ({ pair }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({ structuredContent: { pair: [1, 'x'] } });
|
||||
|
||||
pair = ['x', 1]; // violates the positional item schemas
|
||||
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('2019-09 outputSchema (zod-to-json-schema openAi target shape) validates instead of failing pre-wire', async () => {
|
||||
const { client, close } = await connectPair(OPENAI_TARGET_SCHEMA, () => ({ summary: 'ok', score: 1 }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({
|
||||
structuredContent: { summary: 'ok', score: 1 }
|
||||
});
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('a VIOLATING result against a 2019-09 outputSchema still fails validation', async () => {
|
||||
// `summary` missing — the 2019-09 engine must actually run, not pass through.
|
||||
const { client, close } = await connectPair(OPENAI_TARGET_SCHEMA, () => ({ score: 1 }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('unknown dialect still fails pre-wire with the typed error', async () => {
|
||||
const { client, close } = await connectPair(
|
||||
{ ...FILESYSTEM_STYLE_SCHEMA, $schema: 'http://json-schema.org/draft-04/schema#' },
|
||||
() => ({ content: 'hello' })
|
||||
);
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/invalid outputSchema.*unsupported dialect/s);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('fromJsonSchema registers a draft-07 inputSchema and enforces it server-side', async () => {
|
||||
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
|
||||
mcpServer.registerTool(
|
||||
'echo',
|
||||
{
|
||||
inputSchema: fromJsonSchema<{ content: string; encoding?: 'utf8' | 'base64' }>(
|
||||
FILESYSTEM_STYLE_SCHEMA as unknown as JsonSchemaType
|
||||
)
|
||||
},
|
||||
async args => ({ content: [{ type: 'text', text: JSON.stringify(args) }] })
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
||||
|
||||
await expect(client.callTool({ name: 'echo', arguments: { content: 'hi' } })).resolves.toMatchObject({
|
||||
content: [{ type: 'text', text: expect.stringContaining('hi') }]
|
||||
});
|
||||
|
||||
// Violates the draft-07 schema (`content` missing) — the server reports an input validation error.
|
||||
await expect(client.callTool({ name: 'echo', arguments: { encoding: 'utf8' } })).resolves.toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: expect.stringContaining("must have required property 'content'") }]
|
||||
});
|
||||
|
||||
await Promise.all([client.close(), clientTransport.close(), serverTransport.close()]);
|
||||
});
|
||||
});
|
||||
|
|
@ -986,3 +986,32 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
|
|||
).rejects.toThrow(/^Elicitation response content does not match requested schema/);
|
||||
});
|
||||
}
|
||||
|
||||
describe('declared-dialect requestedSchema (default validator)', () => {
|
||||
test('draft-07-stamped requestedSchema validates accepted content with a real engine', async () => {
|
||||
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const requestedSchema = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', minLength: 1 } },
|
||||
required: ['name']
|
||||
} as const;
|
||||
|
||||
let content: Record<string, unknown> = { name: 'John' };
|
||||
client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content }));
|
||||
|
||||
await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).resolves.toMatchObject({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
});
|
||||
|
||||
content = { name: '' }; // violates minLength — the draft-07 engine actually runs
|
||||
await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).rejects.toThrow(
|
||||
/does not match requested schema/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue