fix(MCP Server Trigger Node): Emit tool schemas as JSON Schema 2020-12 (#36490)

This commit is contained in:
Elias Meire 2026-08-19 07:52:36 +00:00 committed by GitHub
parent 29779f0c5e
commit 25d0be0ec1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 731 additions and 33 deletions

View File

@ -28,6 +28,9 @@
"tokenizer": [
"dist/esm/utils/tokenizer/tiktoken.d.ts"
],
"json-schema": [
"dist/esm/json-schema/index.d.ts"
],
"web-search": [
"dist/esm/web-search/index.d.ts"
],
@ -77,6 +80,11 @@
"import": "./dist/esm/utils/tokenizer/tiktoken.js",
"require": "./dist/cjs/utils/tokenizer/tiktoken.js"
},
"./json-schema": {
"types": "./dist/esm/json-schema/index.d.ts",
"import": "./dist/esm/json-schema/index.js",
"require": "./dist/cjs/json-schema/index.js"
},
"./web-search": {
"types": "./dist/esm/web-search/index.d.ts",
"import": "./dist/esm/web-search/index.js",

View File

@ -0,0 +1,332 @@
import { JSON_SCHEMA_DRAFT_2020_12, toDraft202012 } from './draft-2020-12';
const DRAFT_07 = 'http://json-schema.org/draft-07/schema#';
describe('toDraft202012', () => {
describe('dialect', () => {
it('replaces a draft-07 declaration with 2020-12', () => {
const result = toDraft202012({ $schema: DRAFT_07, type: 'object' });
expect(result.$schema).toBe(JSON_SCHEMA_DRAFT_2020_12);
});
it('declares the dialect on a document that had none', () => {
expect(toDraft202012({ type: 'object' }).$schema).toBe(JSON_SCHEMA_DRAFT_2020_12);
});
it('returns a bare declaration for a non-object document', () => {
expect(toDraft202012(null)).toEqual({ $schema: JSON_SCHEMA_DRAFT_2020_12 });
});
});
describe('tuples', () => {
it('moves positional schemas to prefixItems', () => {
const result = toDraft202012({
type: 'array',
minItems: 2,
maxItems: 2,
items: [{ type: 'string' }, { type: 'number' }],
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'array',
minItems: 2,
maxItems: 2,
prefixItems: [{ type: 'string' }, { type: 'number' }],
});
});
it('folds additionalItems into items for a tuple with a rest element', () => {
const result = toDraft202012({
type: 'array',
items: [{ type: 'string' }],
additionalItems: { type: 'boolean' },
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'array',
prefixItems: [{ type: 'string' }],
items: { type: 'boolean' },
});
});
it('leaves a list schema alone', () => {
const result = toDraft202012({ type: 'array', items: { type: 'string' } });
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'array',
items: { type: 'string' },
});
});
it('drops additionalItems with no tuple to constrain, which draft-07 ignores', () => {
const result = toDraft202012({ type: 'array', additionalItems: { type: 'string' } });
expect(result).toEqual({ $schema: JSON_SCHEMA_DRAFT_2020_12, type: 'array' });
});
it('migrates tuples nested in every subschema position', () => {
const tuple = { type: 'array', items: [{ type: 'string' }] };
const migrated = { type: 'array', prefixItems: [{ type: 'string' }] };
const result = toDraft202012({
type: 'object',
properties: { a: tuple },
patternProperties: { '^b': tuple },
additionalProperties: tuple,
propertyNames: tuple,
not: tuple,
if: tuple,
then: tuple,
else: tuple,
contains: tuple,
allOf: [tuple],
anyOf: [tuple],
oneOf: [tuple],
items: tuple,
});
expect(result).toMatchObject({
properties: { a: migrated },
patternProperties: { '^b': migrated },
additionalProperties: migrated,
propertyNames: migrated,
not: migrated,
if: migrated,
then: migrated,
else: migrated,
contains: migrated,
allOf: [migrated],
anyOf: [migrated],
oneOf: [migrated],
items: migrated,
});
});
it('migrates a tuple nested inside another tuple', () => {
const result = toDraft202012({
type: 'array',
items: [{ type: 'array', items: [{ type: 'string' }] }],
additionalItems: { type: 'array', items: [{ type: 'number' }] },
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'array',
prefixItems: [{ type: 'array', prefixItems: [{ type: 'string' }] }],
items: { type: 'array', prefixItems: [{ type: 'number' }] },
});
});
});
describe('$defs', () => {
it('renames root definitions and repoints refs into it', () => {
const result = toDraft202012({
$ref: '#/definitions/Node',
definitions: {
Node: { type: 'object', properties: { self: { $ref: '#/definitions/Node' } } },
},
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
$ref: '#/$defs/Node',
$defs: { Node: { type: 'object', properties: { self: { $ref: '#/$defs/Node' } } } },
});
});
it('leaves refs that do not point into root definitions', () => {
const result = toDraft202012({
type: 'object',
properties: { a: { $ref: '#/properties/b' }, b: { type: 'string' } },
});
expect(result.properties).toEqual({
a: { $ref: '#/properties/b' },
b: { type: 'string' },
});
});
it('keeps a nested definitions object addressable while still migrating it', () => {
const result = toDraft202012({
type: 'object',
properties: {
a: {
definitions: { Inner: { type: 'array', items: [{ type: 'string' }] } },
$ref: '#/properties/a/definitions/Inner',
},
},
});
expect(result.properties).toEqual({
a: {
definitions: { Inner: { type: 'array', prefixItems: [{ type: 'string' }] } },
$ref: '#/properties/a/definitions/Inner',
},
});
});
});
describe('dependencies', () => {
it('splits property lists into dependentRequired', () => {
const result = toDraft202012({
type: 'object',
dependencies: { card: ['name', 'zip'] },
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'object',
dependentRequired: { card: ['name', 'zip'] },
});
});
it('splits subschemas into dependentSchemas, migrating them', () => {
const result = toDraft202012({
type: 'object',
dependencies: {
card: { properties: { t: { type: 'array', items: [{ type: 'string' }] } } },
},
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'object',
dependentSchemas: {
card: { properties: { t: { type: 'array', prefixItems: [{ type: 'string' }] } } },
},
});
});
it('handles both forms in one document', () => {
const result = toDraft202012({
dependencies: { a: ['b'], c: { required: ['d'] } },
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
dependentRequired: { a: ['b'] },
dependentSchemas: { c: { required: ['d'] } },
});
});
it('does not treat a property named dependencies as the keyword', () => {
const result = toDraft202012({
type: 'object',
properties: { dependencies: { type: 'object' } },
});
expect(result.properties).toEqual({ dependencies: { type: 'object' } });
expect(result).not.toHaveProperty('dependentRequired');
});
});
describe('draft-04 numeric bounds', () => {
it('converts the boolean exclusive form to the numeric form', () => {
const result = toDraft202012({
type: 'number',
minimum: 3,
exclusiveMinimum: true,
maximum: 9,
exclusiveMaximum: true,
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'number',
exclusiveMinimum: 3,
exclusiveMaximum: 9,
});
});
it('keeps inclusive bounds when the flag is false', () => {
const result = toDraft202012({
type: 'number',
minimum: 3,
exclusiveMinimum: false,
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'number',
minimum: 3,
});
});
it('leaves the numeric form alone', () => {
const result = toDraft202012({ type: 'number', exclusiveMinimum: 3, maximum: 9 });
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'number',
exclusiveMinimum: 3,
maximum: 9,
});
});
});
describe('keyword safety', () => {
it.each(['items', 'additionalItems', 'definitions', 'dependentSchemas', 'properties'])(
'does not treat a property named %s as a keyword',
(name) => {
const result = toDraft202012({
type: 'object',
properties: { [name]: { type: 'array', items: [{ type: 'string' }] } },
});
expect(result.properties).toEqual({
[name]: { type: 'array', prefixItems: [{ type: 'string' }] },
});
},
);
it('passes boolean subschemas through', () => {
const result = toDraft202012({
type: 'object',
properties: { a: true, b: false },
additionalProperties: false,
items: [true],
});
expect(result).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
type: 'object',
properties: { a: true, b: false },
additionalProperties: false,
prefixItems: [true],
});
});
it('preserves unrelated keywords, including 2020-12-valid numeric bounds', () => {
const document = {
type: 'object',
title: 'T',
description: 'd',
required: ['a'],
properties: { a: { type: 'number', exclusiveMinimum: 3, multipleOf: 2 } },
enum: [1, 2],
format: 'email',
};
expect(toDraft202012(document)).toEqual({
$schema: JSON_SCHEMA_DRAFT_2020_12,
...document,
});
});
it('is idempotent', () => {
const document = {
type: 'array',
items: [{ type: 'string' }],
additionalItems: { type: 'number' },
definitions: { A: { type: 'string' } },
};
const once = toDraft202012(document);
expect(toDraft202012(once)).toEqual(once);
});
});
});

View File

@ -0,0 +1,128 @@
import { isRecord } from '@n8n/utils/is-record';
export const JSON_SCHEMA_DRAFT_2020_12 = 'https://json-schema.org/draft/2020-12/schema';
const SUBSCHEMA_KEYWORDS = [
'additionalProperties',
'contains',
'contentSchema',
'else',
'if',
'not',
'propertyNames',
'then',
'unevaluatedItems',
'unevaluatedProperties',
];
const SUBSCHEMA_LIST_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
const SUBSCHEMA_MAP_KEYWORDS = [
'$defs',
'definitions',
'dependentSchemas',
'patternProperties',
'properties',
];
const DEFINITIONS_REF_PREFIX = '#/definitions/';
function migrateEach(subschemas: unknown): unknown {
if (!isRecord(subschemas)) return subschemas;
return Object.fromEntries(
Object.entries(subschemas).map(([name, subschema]) => [name, migrate(subschema)]),
);
}
function applyBound(
result: Record<string, unknown>,
inclusiveKeyword: 'minimum' | 'maximum',
exclusiveKeyword: 'exclusiveMinimum' | 'exclusiveMaximum',
exclusive: unknown,
) {
if (exclusive === undefined) return;
const bound = result[inclusiveKeyword];
if (typeof exclusive !== 'boolean') {
result[exclusiveKeyword] = exclusive;
} else if (exclusive && bound !== undefined) {
delete result[inclusiveKeyword];
result[exclusiveKeyword] = bound;
}
}
/**
* Walks by keyword rather than by shape, so a property named `items` or
* `definitions` is never mistaken for the keyword of the same name.
*/
function migrate(schema: unknown): unknown {
// `true`/`false` are valid schemas, and a malformed document can put anything
// here; both pass through untouched.
if (!isRecord(schema)) return schema;
const { items, additionalItems, dependencies, exclusiveMinimum, exclusiveMaximum, ...rest } =
schema;
const result: Record<string, unknown> = {};
for (const [keyword, value] of Object.entries(rest)) {
if (SUBSCHEMA_KEYWORDS.includes(keyword)) {
result[keyword] = migrate(value);
} else if (SUBSCHEMA_LIST_KEYWORDS.includes(keyword)) {
result[keyword] = Array.isArray(value) ? value.map(migrate) : value;
} else if (SUBSCHEMA_MAP_KEYWORDS.includes(keyword)) {
result[keyword] = migrateEach(value);
} else if (keyword === '$ref' && typeof value === 'string') {
result.$ref = value.startsWith(DEFINITIONS_REF_PREFIX)
? `#/$defs/${value.slice(DEFINITIONS_REF_PREFIX.length)}`
: value;
} else {
result[keyword] = value;
}
}
if (Array.isArray(items)) {
// 2020-12 moved draft-07's positional tuple schemas to `prefixItems` and
// reused `items` for what `additionalItems` constrained.
result.prefixItems = items.map(migrate);
if (additionalItems !== undefined) result.items = migrate(additionalItems);
} else if (items !== undefined) {
result.items = migrate(items);
}
if (isRecord(dependencies)) {
for (const [property, dependency] of Object.entries(dependencies)) {
const target = Array.isArray(dependency) ? 'dependentRequired' : 'dependentSchemas';
const bucket = isRecord(result[target]) ? result[target] : {};
result[target] = {
...bucket,
[property]: Array.isArray(dependency) ? dependency : migrate(dependency),
};
}
}
applyBound(result, 'minimum', 'exclusiveMinimum', exclusiveMinimum);
applyBound(result, 'maximum', 'exclusiveMaximum', exclusiveMaximum);
return result;
}
/**
* Rewrites a draft-04/06/07 JSON Schema document as JSON Schema 2020-12, the
* dialect MCP recommends and requires every client to support. draft-07 is the
* only dialect `zod-to-json-schema` can emit, so that is the path in use here.
*
* Output matches `z.toJSONSchema(schema, { target: 'draft-2020-12' })` from Zod v4,
* when we migrate to Zod v4, we can remove this and use the built-in toJSONSchema() instead.
*/
export function toDraft202012(schema: unknown): Record<string, unknown> {
const migrated = migrate(schema);
if (!isRecord(migrated)) return { $schema: JSON_SCHEMA_DRAFT_2020_12 };
const { $schema: _draft07Dialect, definitions, ...rest } = migrated;
return {
$schema: JSON_SCHEMA_DRAFT_2020_12,
...rest,
...(definitions !== undefined ? { $defs: definitions } : {}),
};
}

View File

@ -0,0 +1,118 @@
import { z } from 'zod';
import * as z4 from 'zod/v4';
import { JSON_SCHEMA_DRAFT_2020_12, zodToDraft202012 } from './index';
/** Keywords draft-07 defines that 2020-12 either renamed or dropped. */
const DRAFT_07_ONLY_KEYWORDS = ['additionalItems', 'definitions', 'dependencies'];
function keywordsIn(node: unknown, found = new Set<string>()): Set<string> {
if (Array.isArray(node)) {
for (const entry of node) keywordsIn(entry, found);
} else if (typeof node === 'object' && node !== null) {
for (const [key, value] of Object.entries(node)) {
found.add(key);
keywordsIn(value, found);
}
}
return found;
}
describe('zodToDraft202012', () => {
it('declares 2020-12 rather than draft-07', () => {
const result = zodToDraft202012(z.object({ a: z.string() }));
expect(result.$schema).toBe(JSON_SCHEMA_DRAFT_2020_12);
});
it('spells a tuple with a rest element as prefixItems plus items', () => {
const result = zodToDraft202012(z.tuple([z.string(), z.number()]).rest(z.boolean()));
expect(result).toMatchObject({
type: 'array',
prefixItems: [{ type: 'string' }, { type: 'number' }],
items: { type: 'boolean' },
});
expect(result).not.toHaveProperty('additionalItems');
});
// `strict` inverts which objects are closed: it reads `unknownKeys !== 'strict'`,
// so a plain `z.object` stays open. The MCP Server Trigger relies on that.
it('forwards conversion options', () => {
const shape = z.object({ a: z.string() });
expect(zodToDraft202012(shape).additionalProperties).toBe(false);
expect(
zodToDraft202012(shape, { removeAdditionalStrategy: 'strict' }).additionalProperties,
).toBe(true);
});
it('points a named schema at $defs', () => {
const result = zodToDraft202012(z.object({ a: z.string() }), { name: 'Tool' });
expect(result.$ref).toBe('#/$defs/Tool');
expect(result.$defs).toHaveProperty('Tool');
});
it('repoints refs to a reused subschema', () => {
const shared = z.object({ id: z.string() });
const result = zodToDraft202012(z.object({ a: shared, b: shared }), {
definitions: { Shared: shared },
});
expect(JSON.stringify(result)).not.toContain('#/definitions/');
expect(result.$defs).toHaveProperty('Shared');
});
// Locks the output to the dialect zod v4 emits natively, so replacing this
// function with `z.toJSONSchema(schema, { target: 'draft-2020-12' })` after the
// zod v4 migration cannot change the dialect clients see.
describe('parity with zod v4', () => {
const ours = zodToDraft202012(
z.object({
text: z.string().describe('some text'),
count: z.number().int().min(1),
pair: z.tuple([z.string(), z.number()]).rest(z.boolean()),
choice: z.union([z.literal('a'), z.literal('b')]),
nested: z.object({ deep: z.tuple([z.string()]) }),
}),
);
const native = z4.toJSONSchema(
z4.object({
text: z4.string().describe('some text'),
count: z4.number().int().min(1),
pair: z4.tuple([z4.string(), z4.number()]).rest(z4.boolean()),
choice: z4.union([z4.literal('a'), z4.literal('b')]),
nested: z4.object({ deep: z4.tuple([z4.string()]) }),
}),
{ target: 'draft-2020-12' },
);
it('declares the same dialect', () => {
expect(ours.$schema).toBe(native.$schema);
});
it('declares the dialect first', () => {
expect(Object.keys(ours)[0]).toBe('$schema');
expect(Object.keys(native)[0]).toBe('$schema');
});
it.each([
['ours', () => ours],
['zod v4', () => native],
])('uses no draft-07-only keyword (%s)', (_label, document) => {
const used = keywordsIn(document());
expect(DRAFT_07_ONLY_KEYWORDS.filter((keyword) => used.has(keyword))).toEqual([]);
});
it.each(['pair', 'nested'])('spells the %s tuple the same way', (property) => {
const pick = (document: Record<string, unknown>) => {
const properties = document.properties as Record<string, unknown>;
return keywordsIn(properties[property]);
};
expect(pick(ours).has('prefixItems')).toBe(pick(native).has('prefixItems'));
});
});
});

View File

@ -0,0 +1,19 @@
import type { ZodSchema } from 'zod';
import { zodToJsonSchema, type Options } from 'zod-to-json-schema';
import { toDraft202012 } from './draft-2020-12';
export { JSON_SCHEMA_DRAFT_2020_12, toDraft202012 } from './draft-2020-12';
/**
* Converts a zod schema to a JSON Schema 2020-12 document.
*
* Output matches `z.toJSONSchema(schema, { target: 'draft-2020-12' })` from Zod v4,
* when we migrate to Zod v4, we can remove this and use the built-in toJSONSchema() instead.
*/
export function zodToDraft202012(
schema: ZodSchema,
options?: Partial<Omit<Options<'jsonSchema7'>, 'target'>>,
): Record<string, unknown> {
return toDraft202012(zodToJsonSchema(schema, { ...options, target: 'jsonSchema7' }));
}

View File

@ -1,6 +1,4 @@
import type { Tool } from '@langchain/core/tools';
import { McpServerConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type {
@ -9,12 +7,14 @@ import type {
JSONRPCMessage,
} from '@modelcontextprotocol/sdk/types.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToDraft202012 } from '@n8n/ai-utilities/json-schema';
import { McpServerConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { randomUUID } from 'crypto';
import type * as express from 'express';
import type { IncomingMessage } from 'http';
import type { CredentialCheckResult, Logger } from 'n8n-workflow';
import { jsonParse, OperationalError } from 'n8n-workflow';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ExecutionCoordinator } from './execution/ExecutionCoordinator';
import type { ExecutionStrategy } from './execution/ExecutionStrategy';
@ -43,6 +43,14 @@ import { TransportFactory } from './transport/TransportFactory';
*/
const ELICITATION_TIMEOUT_MS = 300_000;
function toolDescriptors(tools: Tool[]) {
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: zodToDraft202012(tool.schema, { removeAdditionalStrategy: 'strict' }),
}));
}
export interface HandlePostResult {
wasToolCall: boolean;
toolCallInfo?: McpToolCallInfo;
@ -332,18 +340,10 @@ export class McpServer {
`SSE queue mode: handling relayed list tools request for session ${sessionId}`,
);
const tools = this.sessionManager.getTools(sessionId) ?? [];
const toolsList = tools.map((tool) => ({
name: tool.name,
description: tool.description,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
inputSchema: zodToJsonSchema(tool.schema as any, { removeAdditionalStrategy: 'strict' }),
}));
const response: JSONRPCMessage = {
jsonrpc: '2.0',
id: messageId,
result: { tools: toolsList },
result: { tools: toolDescriptors(this.sessionManager.getTools(sessionId) ?? []) },
};
void transport.send(response);
}
@ -608,17 +608,7 @@ export class McpServer {
throw new OperationalError('Require a sessionId for the listing of tools');
}
const tools = this.sessionManager.getTools(extra.sessionId) ?? [];
return {
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
inputSchema: zodToJsonSchema(tool.schema as any, {
removeAdditionalStrategy: 'strict',
}),
})),
};
return { tools: toolDescriptors(this.sessionManager.getTools(extra.sessionId) ?? []) };
},
);

View File

@ -14,6 +14,7 @@ import {
import type { McpServerConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { z } from 'zod';
import { QueuedExecutionStrategy } from '../execution/QueuedExecutionStrategy';
import { McpServer } from '../McpServer';
@ -37,6 +38,83 @@ describe('McpServer', () => {
mcpServer = McpServer.instance(mockLogger);
});
describe('tools/list schema dialect', () => {
const sessionId = 'dialect-session';
const toolWithTuple = () =>
createMockTool('with_tuple', {
schema: z.object({ range: z.tuple([z.number(), z.number()]).rest(z.number()) }),
});
type ListToolsHandler = (
request: unknown,
extra: { sessionId?: string },
) => { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
async function advertisedTools() {
const server = createMockServer();
const internals = mcpServer as unknown as {
sessionManager: SessionManager;
setupHandlers(server: unknown): void;
};
await internals.sessionManager.registerSession(
sessionId,
server,
createMockTransport(sessionId, 'streamableHttp'),
[toolWithTuple()],
);
internals.setupHandlers(server);
const listTools = server.setRequestHandler.mock.calls[0][1] as unknown as ListToolsHandler;
return listTools({}, { sessionId }).tools;
}
it('declares JSON Schema 2020-12', async () => {
const [tool] = await advertisedTools();
expect(tool.inputSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
});
it('advertises tuples with the 2020-12 keywords', async () => {
const [tool] = await advertisedTools();
expect(tool.inputSchema.properties).toMatchObject({
range: {
type: 'array',
prefixItems: [{ type: 'number' }, { type: 'number' }],
items: { type: 'number' },
},
});
expect(JSON.stringify(tool.inputSchema)).not.toContain('additionalItems');
});
it('keeps plain objects open to extra keys', async () => {
const [tool] = await advertisedTools();
expect(tool.inputSchema.additionalProperties).toBe(true);
});
it('relays the same dialect over SSE', async () => {
const server = createMockServer();
const transport = createMockTransport(sessionId, 'sse');
const internals = mcpServer as unknown as { sessionManager: SessionManager };
await internals.sessionManager.registerSession(sessionId, server, transport, [
toolWithTuple(),
]);
mcpServer.handleWorkerResponse(sessionId, 'msg-1', { _listToolsRequest: true });
const [message] = transport.send.mock.calls[0] as unknown as [
{ result: { tools: Array<{ inputSchema: Record<string, unknown> }> } },
];
expect(message.result.tools[0].inputSchema).toMatchObject({
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: { range: { prefixItems: [{ type: 'number' }, { type: 'number' }] } },
});
});
});
afterEach(() => {
mcpServer.stopSweep();
// Clean up singleton

View File

@ -12,6 +12,7 @@ export function createMockTool(
invokeReturn?: unknown;
invokeError?: Error;
metadata?: Record<string, unknown>;
schema?: z.ZodTypeAny;
} = {},
): Mocked<Tool> {
const {
@ -19,6 +20,7 @@ export function createMockTool(
invokeReturn = { result: 'success' },
invokeError,
metadata,
schema = z.object({}),
} = opts;
const invoke = vi.fn().mockImplementation(async () => {
@ -32,7 +34,7 @@ export function createMockTool(
return {
name: toolName,
description,
schema: z.object({}),
schema,
invoke,
metadata,
} as unknown as Mocked<Tool>;

View File

@ -8,10 +8,12 @@ describe('shapeToStandardSchema', () => {
limit: z.number().int().min(1),
});
it('advertises the shape as JSON Schema without a $schema marker', () => {
// Claude Desktop and other strict clients reject a tool whose schema declares
// draft-07, and MCP requires every client to support 2020-12.
it('advertises the shape as JSON Schema 2020-12', () => {
const json = schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
expect(json.$schema).toBeUndefined();
expect(json.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
expect(json).toMatchObject({
type: 'object',
properties: {
@ -22,6 +24,31 @@ describe('shapeToStandardSchema', () => {
});
});
it('advertises tuples with the 2020-12 keywords', () => {
const withTuple = shapeToStandardSchema({
range: z.tuple([z.number(), z.number()]).rest(z.number()),
});
const json = withTuple['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
expect(json.properties).toMatchObject({
range: {
type: 'array',
prefixItems: [{ type: 'number' }, { type: 'number' }],
items: { type: 'number' },
},
});
expect(JSON.stringify(json)).not.toContain('additionalItems');
});
it('declares the same dialect on input and output', () => {
const standard = schema['~standard'];
expect(standard.jsonSchema.output({ target: 'draft-2020-12' }).$schema).toBe(
standard.jsonSchema.input({ target: 'draft-2020-12' }).$schema,
);
});
it('validates through the original zod object', async () => {
const good = await schema['~standard'].validate({ query: 'hi', limit: 3 });
expect(good).toEqual({ value: { query: 'hi', limit: 3 } });

View File

@ -1,14 +1,13 @@
import type { StandardSchemaWithJSON } from '@modelcontextprotocol/server';
import { zodToDraft202012 } from '@n8n/ai-utilities/json-schema';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
/**
* Bridges a classic-zod raw shape to the Standard Schema interface the v2 MCP
* SDK requires. The SDK only uses `~standard.validate` (argument checking) and
* `~standard.jsonSchema` (the shape advertised in tools/list); validation stays
* on the exact zod object we validate with today, so tool semantics don't
* change. Removable once the MCP tool schemas move to zod v4, which implements
* this interface natively.
* change.
*/
export function shapeToStandardSchema<Shape extends z.ZodRawShape>(
shape: Shape,
@ -17,10 +16,7 @@ export function shapeToStandardSchema<Shape extends z.ZodRawShape>(
z.objectOutputType<Shape, z.ZodTypeAny>
> {
const schema = z.object(shape);
const jsonSchema: Record<string, unknown> = { ...zodToJsonSchema(schema) };
// zod-to-json-schema stamps a $schema draft marker; the SDK emits schemas
// without one, and clients treat its presence inconsistently.
delete jsonSchema.$schema;
const jsonSchema = zodToDraft202012(schema);
return {
'~standard': {