From 37e47e3cec5d6029b20346f83fb47f24828f5ffb Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Thu, 28 May 2026 09:51:16 +0200 Subject: [PATCH] fix(core): Synthesize type-defs for MCP registry nodes at request time (#30887) --- .../instance-ai.adapter.service.ts | 22 +++++- .../__tests__/synthesize-type-def.test.ts | 69 ++++++++++++++++++ .../mcp-registry/synthesize-type-def.ts | 70 +++++++++++++++++++ .../src/node-catalog/node-catalog.service.ts | 51 +++++++++++++- 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/modules/mcp-registry/__tests__/synthesize-type-def.test.ts create mode 100644 packages/cli/src/modules/mcp-registry/synthesize-type-def.ts diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index c65d972b8da..d29321906e1 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -108,6 +108,8 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { NodeTypes } from '@/node-types'; import { DataTableRepository } from '@/modules/data-table/data-table.repository'; import { DataTableService } from '@/modules/data-table/data-table.service'; +import { MCP_REGISTRY_PACKAGE_NAME } from '@/modules/mcp-registry/node-description-transform'; +import { synthesizeMcpRegistryTypeDef } from '@/modules/mcp-registry/synthesize-type-def'; import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import { userHasScopes } from '@/permissions.ee/check-access'; import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; @@ -1970,13 +1972,31 @@ export class InstanceAiAdapterService { }, getNodeTypeDefinition: async (nodeType, options) => { + const nodes = await getNodes(); + + // Synthetic MCP registry nodes have no on-disk type-def, so the + // standard resolver would 404 on them. Match either the bare slug + // (e.g. `notion`) or the package-prefixed form, then synthesise + // the TypeScript content from the in-memory description. + const registryNode = + nodes.find((n) => n.name === `${MCP_REGISTRY_PACKAGE_NAME}.${nodeType}`) ?? + (nodeType.startsWith(`${MCP_REGISTRY_PACKAGE_NAME}.`) + ? nodes.find((n) => n.name === nodeType) + : undefined); + if (registryNode) { + const builderHint = registryNode.builderHint?.searchHint; + return { + content: synthesizeMcpRegistryTypeDef(registryNode), + ...(builderHint ? { builderHint } : {}), + }; + } + const result = resolveNodeTypeDefinition(nodeType, this.getNodeDefinitionDirs(), options); if (result.error) { return { content: '', error: result.error }; } - const nodes = await getNodes(); const nodeDesc = findNodeByVersion( nodes, nodeType, diff --git a/packages/cli/src/modules/mcp-registry/__tests__/synthesize-type-def.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/synthesize-type-def.test.ts new file mode 100644 index 00000000000..3c145499073 --- /dev/null +++ b/packages/cli/src/modules/mcp-registry/__tests__/synthesize-type-def.test.ts @@ -0,0 +1,69 @@ +import type { INodeTypeDescription } from 'n8n-workflow'; + +import { serverToNodeDescription } from '../node-description-transform'; +import { notionMockServer, linearMockServer } from '../registry/mock-servers'; +import { synthesizeMcpRegistryTypeDef } from '../synthesize-type-def'; + +const baseDescription: INodeTypeDescription = { + displayName: 'MCP Registry Client Tool', + name: 'mcpRegistryClientTool', + icon: 'fa:server', + group: ['output'], + version: 1, + description: 'Connect to a registry-backed MCP server', + defaults: { name: 'MCP Registry Client Tool' }, + inputs: [], + outputs: [], + codex: { + alias: ['MCP', 'Model Context Protocol'], + categories: ['AI'], + subcategories: { AI: ['Model Context Protocol'] }, + }, + credentials: [], + properties: [ + { + displayName: 'Endpoint URL', + name: 'endpointUrl', + type: 'hidden', + default: '', + }, + { + displayName: 'Server Transport', + name: 'serverTransport', + type: 'hidden', + default: 'httpStreamable', + }, + { + displayName: 'Tools to Include', + name: 'include', + type: 'options', + default: 'all', + options: [], + }, + ], +}; + +describe('synthesizeMcpRegistryTypeDef', () => { + it('produces TypeScript content for the Notion registry node', () => { + const description = serverToNodeDescription(notionMockServer, baseDescription); + expect(description).not.toBeNull(); + + const content = synthesizeMcpRegistryTypeDef(description!); + + expect(content).toContain('notionMcpOAuth2Api'); + expect(content).toContain('export'); + // Hidden fields should not leak into the agent-facing schema. + expect(content).not.toContain('endpointUrl'); + expect(content).not.toContain('serverTransport'); + }); + + it('produces TypeScript content for the Linear registry node', () => { + const description = serverToNodeDescription(linearMockServer, baseDescription); + expect(description).not.toBeNull(); + + const content = synthesizeMcpRegistryTypeDef(description!); + + expect(content).toContain('linearMcpOAuth2Api'); + expect(content).toContain('export'); + }); +}); diff --git a/packages/cli/src/modules/mcp-registry/synthesize-type-def.ts b/packages/cli/src/modules/mcp-registry/synthesize-type-def.ts new file mode 100644 index 00000000000..53eece25077 --- /dev/null +++ b/packages/cli/src/modules/mcp-registry/synthesize-type-def.ts @@ -0,0 +1,70 @@ +import { generateNodeTypeFile } from '@n8n/workflow-sdk'; +import type { NodeTypeDescription as SdkNodeTypeDescription } from '@n8n/workflow-sdk'; +import type { INodeTypeDescription } from 'n8n-workflow'; + +/** + * Generate TypeScript type-definition content for a synthetic MCP registry + * node by running its in-memory description through the SDK's standard + * generator. The output shape matches the on-disk `dist/node-definitions/` + * files produced for native nodes at build time, so consumers + * (Agent Builder's `get_node_types`, Instance AI's `type-definition`) can + * treat it identically. + * + * Hidden properties (pre-configured connection details like the endpoint + * URL and server transport) are stripped before generation so the agent's + * schema only surfaces parameters the agent is meant to set. + */ +export function synthesizeMcpRegistryTypeDef(description: INodeTypeDescription): string { + const visibleDescription = { + ...description, + properties: description.properties.filter((property) => property.type !== 'hidden'), + }; + + if (!isSdkNodeTypeDescription(visibleDescription)) { + throw new Error(`Cannot synthesize MCP registry type definition for ${description.name}`); + } + + return generateNodeTypeFile(visibleDescription); +} + +function isSdkNodeTypeDescription( + description: INodeTypeDescription, +): description is INodeTypeDescription & SdkNodeTypeDescription { + return ( + Array.isArray(description.group) && + Array.isArray(description.properties) && + typeof description.inputs !== 'string' && + typeof description.outputs !== 'string' && + hasSdkConnections(description.inputs) && + hasSdkConnections(description.outputs) && + hasSdkCredentials(description.credentials) + ); +} + +function hasSdkConnections( + connections: INodeTypeDescription['inputs'] | INodeTypeDescription['outputs'], +): connections is (INodeTypeDescription['inputs'] | INodeTypeDescription['outputs']) & + SdkNodeTypeDescription['inputs'] { + return ( + Array.isArray(connections) && + connections.every( + (connection) => + typeof connection === 'string' || + (typeof connection.type === 'string' && + (connection.displayName === undefined || typeof connection.displayName === 'string')), + ) + ); +} + +function hasSdkCredentials( + credentials: INodeTypeDescription['credentials'], +): credentials is SdkNodeTypeDescription['credentials'] { + return ( + credentials === undefined || + credentials.every( + (credential) => + typeof credential.name === 'string' && + (credential.required === undefined || typeof credential.required === 'boolean'), + ) + ); +} diff --git a/packages/cli/src/node-catalog/node-catalog.service.ts b/packages/cli/src/node-catalog/node-catalog.service.ts index a81febb5677..16cdb1a3412 100644 --- a/packages/cli/src/node-catalog/node-catalog.service.ts +++ b/packages/cli/src/node-catalog/node-catalog.service.ts @@ -6,9 +6,12 @@ import type { import { Logger } from '@n8n/backend-common'; import { Service } from '@n8n/di'; import * as fs from 'fs/promises'; +import type { INodeTypeDescription } from 'n8n-workflow'; import * as path from 'path'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { MCP_REGISTRY_PACKAGE_NAME } from '@/modules/mcp-registry/node-description-transform'; +import { synthesizeMcpRegistryTypeDef } from '@/modules/mcp-registry/synthesize-type-def'; export type NodeFilter = (nodeId: string) => boolean; @@ -42,6 +45,13 @@ export class NodeCatalogService { private nodeDefinitionDirs: string[] = []; + /** + * Synthetic MCP registry node descriptions indexed by their prefixed name + * (e.g. `@n8n/mcp-registry.notion`). Used by `getNodeTypes` to synthesise + * type-def content for registry slugs, which have no on-disk artifact. + */ + private mcpRegistryDescriptions = new Map(); + private initPromise: Promise | undefined; /** @@ -126,8 +136,33 @@ export class NodeCatalogService { const cached = this.getCache.get(cacheKey); if (cached) return cached; - const { getNodeTypes } = await import('@n8n/ai-utilities/node-catalog'); - const result = getNodeTypes(nodeIds, { nodeDefinitionDirs: this.nodeDefinitionDirs }); + const registryIds: NodeRequest[] = []; + const onDiskIds: NodeRequest[] = []; + for (const id of nodeIds) { + const nodeId = typeof id === 'string' ? id : id.nodeId; + if (nodeId.startsWith(`${MCP_REGISTRY_PACKAGE_NAME}.`)) { + registryIds.push(id); + } else { + onDiskIds.push(id); + } + } + + const parts: string[] = []; + + for (const id of registryIds) { + const nodeId = typeof id === 'string' ? id : id.nodeId; + const description = this.mcpRegistryDescriptions.get(nodeId); + if (description) { + parts.push(synthesizeMcpRegistryTypeDef(description)); + } + } + + if (onDiskIds.length > 0) { + const { getNodeTypes } = await import('@n8n/ai-utilities/node-catalog'); + parts.push(getNodeTypes(onDiskIds, { nodeDefinitionDirs: this.nodeDefinitionDirs })); + } + + const result = parts.join('\n\n'); this.getCache.set(cacheKey, result); return result; } @@ -152,6 +187,7 @@ export class NodeCatalogService { const { nodes: nodeTypeDescriptions } = await this.loadNodesAndCredentials.collectTypes(); this.nodeTypeParser = new NodeTypeParserClass(nodeTypeDescriptions); + this.indexMcpRegistryDescriptions(nodeTypeDescriptions); this.nodeDefinitionDirs = await this.resolveBuiltinNodeDefinitionDirs(); setSchemaBaseDirs(this.nodeDefinitionDirs); @@ -168,6 +204,7 @@ export class NodeCatalogService { const { NodeTypeParser: NodeTypeParserClass } = await import('@n8n/ai-utilities/node-catalog'); const { nodes: nodeTypeDescriptions } = await this.loadNodesAndCredentials.collectTypes(); this.nodeTypeParser = new NodeTypeParserClass(nodeTypeDescriptions); + this.indexMcpRegistryDescriptions(nodeTypeDescriptions); this.searchStates.clear(); @@ -179,6 +216,16 @@ export class NodeCatalogService { }); } + private indexMcpRegistryDescriptions(descriptions: INodeTypeDescription[]): void { + this.mcpRegistryDescriptions.clear(); + const prefix = `${MCP_REGISTRY_PACKAGE_NAME}.`; + for (const description of descriptions) { + if (description.name.startsWith(prefix)) { + this.mcpRegistryDescriptions.set(description.name, description); + } + } + } + private async resolveBuiltinNodeDefinitionDirs(): Promise { const dirs: string[] = []; for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {