mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(MCP Server Trigger Node): Emit tool schemas as JSON Schema 2020-12 (#36490)
This commit is contained in:
parent
29779f0c5e
commit
25d0be0ec1
|
|
@ -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",
|
||||
|
|
|
|||
332
packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.test.ts
Normal file
332
packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.ts
Normal file
128
packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
118
packages/@n8n/ai-utilities/src/json-schema/index.test.ts
Normal file
118
packages/@n8n/ai-utilities/src/json-schema/index.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
19
packages/@n8n/ai-utilities/src/json-schema/index.ts
Normal file
19
packages/@n8n/ai-utilities/src/json-schema/index.ts
Normal 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' }));
|
||||
}
|
||||
|
|
@ -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) ?? []) };
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user