diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index d825ffb0b5c..5ed0706dccc 100644 --- a/packages/@n8n/ai-utilities/package.json +++ b/packages/@n8n/ai-utilities/package.json @@ -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", diff --git a/packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.test.ts b/packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.test.ts new file mode 100644 index 00000000000..03f296f0c31 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.test.ts @@ -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); + }); + }); +}); diff --git a/packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.ts b/packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.ts new file mode 100644 index 00000000000..4e1538ad556 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/json-schema/draft-2020-12.ts @@ -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, + 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 = {}; + + 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 { + 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 } : {}), + }; +} diff --git a/packages/@n8n/ai-utilities/src/json-schema/index.test.ts b/packages/@n8n/ai-utilities/src/json-schema/index.test.ts new file mode 100644 index 00000000000..c7716660d22 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/json-schema/index.test.ts @@ -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()): Set { + 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) => { + const properties = document.properties as Record; + return keywordsIn(properties[property]); + }; + + expect(pick(ours).has('prefixItems')).toBe(pick(native).has('prefixItems')); + }); + }); +}); diff --git a/packages/@n8n/ai-utilities/src/json-schema/index.ts b/packages/@n8n/ai-utilities/src/json-schema/index.ts new file mode 100644 index 00000000000..7cbd81ad74c --- /dev/null +++ b/packages/@n8n/ai-utilities/src/json-schema/index.ts @@ -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, 'target'>>, +): Record { + return toDraft202012(zodToJsonSchema(schema, { ...options, target: 'jsonSchema7' })); +} diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts index b7b424120fa..75496bc1462 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/McpServer.ts @@ -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) ?? []) }; }, ); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/McpServer.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/McpServer.test.ts index ff591101805..4fbb08f1129 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/McpServer.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/McpServer.test.ts @@ -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 }> }; + + 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 }> } }, + ]; + 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 diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/helpers/mock-langchain.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/helpers/mock-langchain.ts index f155b0a9fc9..c0b852786cb 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/helpers/mock-langchain.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpTrigger/__tests__/helpers/mock-langchain.ts @@ -12,6 +12,7 @@ export function createMockTool( invokeReturn?: unknown; invokeError?: Error; metadata?: Record; + schema?: z.ZodTypeAny; } = {}, ): Mocked { 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; diff --git a/packages/cli/src/modules/mcp/__tests__/tool-schema.util.test.ts b/packages/cli/src/modules/mcp/__tests__/tool-schema.util.test.ts index adf86b08828..8430921e29e 100644 --- a/packages/cli/src/modules/mcp/__tests__/tool-schema.util.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/tool-schema.util.test.ts @@ -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 } }); diff --git a/packages/cli/src/modules/mcp/tool-schema.util.ts b/packages/cli/src/modules/mcp/tool-schema.util.ts index e21d67c2a2b..25380c7a327 100644 --- a/packages/cli/src/modules/mcp/tool-schema.util.ts +++ b/packages/cli/src/modules/mcp/tool-schema.util.ts @@ -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: Shape, @@ -17,10 +16,7 @@ export function shapeToStandardSchema( z.objectOutputType > { const schema = z.object(shape); - const jsonSchema: Record = { ...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': {