feat(core): Synthesize type definitions for custom and community nodes in MCP (#32885)

This commit is contained in:
Ricardo Espinoza
2026-06-29 11:59:51 -04:00
committed by GitHub
parent 799b7899e6
commit c5689d44e2
12 changed files with 299 additions and 43 deletions
+1
View File
@@ -4,6 +4,7 @@ export * from './community-nodes';
export * from './instance';
export * from './execution';
export * from './logstreaming';
export * from './nodes';
export const LICENSE_FEATURES = {
SHARING: 'feat:sharing',
+2
View File
@@ -0,0 +1,2 @@
/** Packages whose node type definitions are generated to disk at build time. */
export const BUILTIN_NODES_PACKAGES = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'] as const;
@@ -109,7 +109,7 @@ 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 { synthesizeNodeTypeDef } 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 { FolderService } from '@/services/folder.service';
@@ -2096,7 +2096,7 @@ export class InstanceAiAdapterService {
if (registryNode) {
const builderHint = registryNode.builderHint?.searchHint;
return {
content: synthesizeMcpRegistryTypeDef(registryNode),
content: synthesizeNodeTypeDef(registryNode),
...(builderHint ? { builderHint } : {}),
};
}
@@ -8,6 +8,7 @@
import { parseNodeId, toSnakeCase, isValidPathComponent } from '@n8n/ai-utilities/node-catalog';
import { safeJoinPath } from '@n8n/backend-common';
import { BUILTIN_NODES_PACKAGES } from '@n8n/constants';
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { dirname } from 'node:path';
@@ -357,7 +358,7 @@ export function resolveNodeTypeDefinition(
*/
export function resolveBuiltinNodeDefinitionDirs(): string[] {
const dirs: string[] = [];
for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {
for (const packageId of BUILTIN_NODES_PACKAGES) {
try {
const packageJsonPath = require.resolve(`${packageId}/package.json`);
const distDir = dirname(packageJsonPath);
@@ -2,7 +2,7 @@ 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';
import { synthesizeNodeTypeDef } from '../synthesize-type-def';
const baseDescription: INodeTypeDescription = {
displayName: 'MCP Registry Client Tool',
@@ -46,7 +46,7 @@ const baseDescription: INodeTypeDescription = {
// oauth2 servers never consult the predicate; this stub keeps those calls type-correct.
const isKnownCredentialType = () => true;
describe('synthesizeMcpRegistryTypeDef', () => {
describe('synthesizeNodeTypeDef', () => {
it('produces TypeScript content for the Notion registry node', () => {
const description = serverToNodeDescription(
notionMockServer,
@@ -55,7 +55,7 @@ describe('synthesizeMcpRegistryTypeDef', () => {
);
expect(description).not.toBeNull();
const content = synthesizeMcpRegistryTypeDef(description!);
const content = synthesizeNodeTypeDef(description!);
expect(content).toContain('notionMcpOAuth2Api');
expect(content).toContain('export');
@@ -72,7 +72,7 @@ describe('synthesizeMcpRegistryTypeDef', () => {
);
expect(description).not.toBeNull();
const content = synthesizeMcpRegistryTypeDef(description!);
const content = synthesizeNodeTypeDef(description!);
expect(content).toContain('linearMcpOAuth2Api');
expect(content).toContain('export');
@@ -3,25 +3,31 @@ import type { NodeTypeDescription as SdkNodeTypeDescription } from '@n8n/workflo
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.
* Generate TypeScript type-definition content for an in-memory node
* description by running it through the SDK's standard generator. The output
* shape matches the on-disk `dist/node-definitions/` files produced for
* built-in nodes at build time, so consumers (MCP `get_node_types`, Instance
* AI's `type-definition`) treat synthesized and on-disk defs 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.
* Used for nodes that have no on-disk artifact: MCP registry servers, custom
* nodes (`N8N_CUSTOM_EXTENSIONS` / `~/.n8n/custom`) and community packages.
*
* Hidden properties (e.g. pre-configured connection details) are stripped
* before generation so the agent's schema only surfaces parameters it is
* meant to set.
*
* Throws when the description cannot be expressed as an SDK type (e.g. nodes
* with expression-computed inputs/outputs). Callers batching multiple nodes
* should catch and degrade gracefully rather than failing the whole request.
*/
export function synthesizeMcpRegistryTypeDef(description: INodeTypeDescription): string {
export function synthesizeNodeTypeDef(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}`);
throw new Error(`Cannot synthesize type definition for ${description.name}`);
}
return generateNodeTypeFile(visibleDescription);
@@ -11,7 +11,9 @@ import { McpServerApiKeyService } from '../mcp-api-key.service';
// endpointGroups mirrors a production instance where isApiEnabled() returns
// false — the MCP endpoint group is still set up so we can exercise the real
// strategy-registration wiring.
utils.setupTestServer({ modules: ['mcp'], endpointGroups: ['mcp'] });
// Loading the MCP module plus DB init can exceed the default 30s hook timeout
// on the Postgres CI shard under load; give the shared setup extra headroom.
utils.setupTestServer({ modules: ['mcp'], endpointGroups: ['mcp'], setupTimeout: 60_000 });
describe('McpServerApiKeyService.verifyApiKey with public API disabled', () => {
it('still authenticates valid MCP API keys', async () => {
@@ -11,6 +11,7 @@ const mockSetSchemaBaseDirs = jest.fn();
const mockSearchCodeBuilderNodes = jest.fn();
const mockGetNodeTypes = jest.fn().mockReturnValue('get-result');
const mockGetSuggestedNodes = jest.fn().mockReturnValue('suggest-result');
const mockGenerateNodeTypeFile = jest.fn().mockReturnValue('synth-result');
jest.mock('@n8n/ai-utilities/node-catalog', () => ({
NodeTypeParser: MockNodeTypeParser,
@@ -21,6 +22,7 @@ jest.mock('@n8n/ai-utilities/node-catalog', () => ({
jest.mock('@n8n/workflow-sdk', () => ({
setSchemaBaseDirs: (...args: unknown[]) => mockSetSchemaBaseDirs(...(args as [string[]])),
generateNodeTypeFile: (...args: unknown[]) => mockGenerateNodeTypeFile(...args),
}));
jest.mock('fs', () => ({
@@ -262,6 +264,169 @@ describe('NodeCatalogService', () => {
expect(mockGetNodeTypes).toHaveBeenCalledTimes(1);
});
test('synthesizes type definitions for a community node instead of the on-disk lookup', async () => {
loadNodesAndCredentials.collectTypes.mockResolvedValue({
nodes: [
{
name: 'n8n-nodes-resend.resend',
group: ['transform'],
properties: [],
inputs: ['main'],
outputs: ['main'],
},
],
} as never);
await service.initialize();
const result = await service.getNodeTypes(['n8n-nodes-resend.resend']);
expect(mockGenerateNodeTypeFile).toHaveBeenCalledTimes(1);
expect(result).toContain('synth-result');
// Community nodes have no on-disk artifact, so the disk lookup is skipped.
expect(mockGetNodeTypes).not.toHaveBeenCalled();
});
test('uses the on-disk lookup for built-in nodes', async () => {
await service.initialize();
const result = await service.getNodeTypes(['n8n-nodes-base.set']);
expect(mockGetNodeTypes).toHaveBeenCalledTimes(1);
expect(mockGetNodeTypes).toHaveBeenCalledWith(
['n8n-nodes-base.set'],
expect.objectContaining({ nodeDefinitionDirs: expect.any(Array) }),
);
expect(result).toBe('get-result');
expect(mockGenerateNodeTypeFile).not.toHaveBeenCalled();
});
test('degrades gracefully when a node type cannot be synthesized', async () => {
loadNodesAndCredentials.collectTypes.mockResolvedValue({
nodes: [
{
name: 'n8n-nodes-resend.resend',
group: ['transform'],
properties: [],
inputs: ['main'],
outputs: ['main'],
},
{
// Expression-computed inputs can't be expressed as an SDK type.
name: 'n8n-nodes-dynamic.dynamic',
group: ['transform'],
properties: [],
inputs: '={{ $json.connections }}',
outputs: ['main'],
},
],
} as never);
await service.initialize();
const result = await service.getNodeTypes([
'n8n-nodes-resend.resend',
'n8n-nodes-dynamic.dynamic',
]);
// The resolvable node still comes through; the unresolvable one is noted, not thrown.
expect(result).toContain('synth-result');
expect(result).toContain('# Errors');
expect(result).toContain('n8n-nodes-dynamic.dynamic');
});
test('synthesizes the latest version of a versioned node by default', async () => {
loadNodesAndCredentials.collectTypes.mockResolvedValue({
nodes: [
{
name: 'n8n-nodes-multi.multi',
version: 1,
group: ['transform'],
properties: [],
inputs: ['main'],
outputs: ['main'],
},
{
name: 'n8n-nodes-multi.multi',
version: 2,
group: ['transform'],
properties: [],
inputs: ['main'],
outputs: ['main'],
},
],
} as never);
await service.initialize();
await service.getNodeTypes(['n8n-nodes-multi.multi']);
expect(mockGenerateNodeTypeFile).toHaveBeenCalledTimes(1);
expect(mockGenerateNodeTypeFile).toHaveBeenCalledWith(
expect.objectContaining({ version: 2 }),
);
});
test('synthesizes the requested version of a versioned node', async () => {
loadNodesAndCredentials.collectTypes.mockResolvedValue({
nodes: [
{
name: 'n8n-nodes-multi.multi',
version: 1,
group: ['transform'],
properties: [],
inputs: ['main'],
outputs: ['main'],
},
{
name: 'n8n-nodes-multi.multi',
version: 2,
group: ['transform'],
properties: [],
inputs: ['main'],
outputs: ['main'],
},
],
} as never);
await service.initialize();
await service.getNodeTypes([{ nodeId: 'n8n-nodes-multi.multi', version: '1' }]);
expect(mockGenerateNodeTypeFile).toHaveBeenCalledWith(
expect.objectContaining({ version: 1 }),
);
});
test('reports an error for an unknown requested version instead of downgrading', async () => {
loadNodesAndCredentials.collectTypes.mockResolvedValue({
nodes: [
{
name: 'n8n-nodes-multi.multi',
version: 1,
group: ['transform'],
properties: [],
inputs: ['main'],
outputs: ['main'],
},
{
name: 'n8n-nodes-multi.multi',
version: 2,
group: ['transform'],
properties: [],
inputs: ['main'],
outputs: ['main'],
},
],
} as never);
await service.initialize();
const result = await service.getNodeTypes([
{ nodeId: 'n8n-nodes-multi.multi', version: '5' },
]);
// No silent downgrade: the missing version is reported with what's available.
expect(mockGenerateNodeTypeFile).not.toHaveBeenCalled();
expect(result).toContain("Version '5' not found for node 'n8n-nodes-multi.multi'");
expect(result).toContain('Available versions: 1, 2');
});
});
describe('getSuggestedNodes', () => {
@@ -4,17 +4,31 @@ import type {
NodeTypeParser,
} from '@n8n/ai-utilities/node-catalog';
import { Logger } from '@n8n/backend-common';
import { BUILTIN_NODES_PACKAGES } from '@n8n/constants';
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';
import { synthesizeNodeTypeDef } from '@/modules/mcp-registry/synthesize-type-def';
export type NodeFilter = (nodeId: string) => boolean;
/**
* Built-in node IDs resolve through the richer, discriminator-aware on-disk
* lookup; everything else (MCP registry, custom and community nodes) is
* synthesized from its in-memory description.
*/
const isBuiltinNodeId = (nodeId: string): boolean =>
BUILTIN_NODES_PACKAGES.some((pkg) => nodeId.startsWith(`${pkg}.`));
const nodeVersionNumbers = (description: INodeTypeDescription): number[] =>
Array.isArray(description.version) ? description.version : [description.version];
const maxNodeVersion = (description: INodeTypeDescription): number =>
Math.max(...nodeVersionNumbers(description));
export interface SearchNodesOptions {
/**
* Optional predicate restricting which node IDs are included in search results.
@@ -46,11 +60,15 @@ 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.
* All loaded node descriptions indexed by their type name (e.g.
* `n8n-nodes-base.set`, `@n8n/mcp-registry.notion`, `n8n-nodes-resend.resend`).
* Used by `getNodeTypes` to synthesise type-def content for non-built-in
* nodes (registry, custom and community), which have no on-disk artifact.
*
* Versioned nodes contribute one description per version under the same name,
* so values are arrays; `selectDescription` picks the requested or latest one.
*/
private mcpRegistryDescriptions = new Map<string, INodeTypeDescription>();
private descriptionsById = new Map<string, INodeTypeDescription[]>();
private initPromise: Promise<void> | undefined;
@@ -136,24 +154,55 @@ export class NodeCatalogService {
const cached = this.getCache.get(cacheKey);
if (cached) return cached;
const registryIds: NodeRequest[] = [];
// Built-in nodes resolve through the on-disk type defs (richer,
// discriminator-aware). Everything else (MCP registry, custom and
// community nodes) has no on-disk artifact, so synthesize from the
// in-memory description collected from the loaders.
const onDiskIds: NodeRequest[] = [];
const synthesizeIds: 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 {
if (isBuiltinNodeId(nodeId)) {
onDiskIds.push(id);
} else {
synthesizeIds.push(id);
}
}
const parts: string[] = [];
const errors: string[] = [];
for (const id of registryIds) {
for (const id of synthesizeIds) {
const nodeId = typeof id === 'string' ? id : id.nodeId;
const description = this.mcpRegistryDescriptions.get(nodeId);
if (description) {
parts.push(synthesizeMcpRegistryTypeDef(description));
const requestedVersion = typeof id === 'string' ? undefined : id.version;
const candidates = this.descriptionsById.get(nodeId);
if (!candidates?.length) {
errors.push(
`Node type '${nodeId}' not found. Use search_nodes to find the correct node ID.`,
);
continue;
}
const description = this.selectDescription(candidates, requestedVersion);
if (!description) {
// Explicit version requested but no match: surface an error rather
// than silently downgrading to a different version's type defs.
const available = [...new Set(candidates.flatMap(nodeVersionNumbers))].sort(
(a, b) => a - b,
);
errors.push(
`Version '${requestedVersion}' not found for node '${nodeId}'. Available versions: ${available.join(', ')}.`,
);
continue;
}
try {
parts.push(synthesizeNodeTypeDef(description));
} catch (error) {
// Some nodes (e.g. expression-computed inputs/outputs) can't be
// expressed as an SDK type. Skip rather than failing the batch.
this.logger.debug('Could not synthesize node type definition', { nodeId, error });
errors.push(
`Type definition for '${nodeId}' is unavailable because the node uses a dynamic structure.`,
);
}
}
@@ -162,6 +211,10 @@ export class NodeCatalogService {
parts.push(getNodeTypes(onDiskIds, { nodeDefinitionDirs: this.nodeDefinitionDirs }));
}
if (errors.length > 0) {
parts.push(`# Errors\n\n${errors.join('\n')}`);
}
const result = parts.join('\n\n');
this.getCache.set(cacheKey, result);
return result;
@@ -187,7 +240,7 @@ export class NodeCatalogService {
const { nodes: nodeTypeDescriptions } = await this.loadNodesAndCredentials.collectTypes();
this.nodeTypeParser = new NodeTypeParserClass(nodeTypeDescriptions);
this.indexMcpRegistryDescriptions(nodeTypeDescriptions);
this.indexDescriptions(nodeTypeDescriptions);
this.nodeDefinitionDirs = await this.resolveBuiltinNodeDefinitionDirs();
setSchemaBaseDirs(this.nodeDefinitionDirs);
@@ -204,7 +257,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.indexDescriptions(nodeTypeDescriptions);
this.searchStates.clear();
@@ -216,19 +269,41 @@ export class NodeCatalogService {
});
}
private indexMcpRegistryDescriptions(descriptions: INodeTypeDescription[]): void {
this.mcpRegistryDescriptions.clear();
const prefix = `${MCP_REGISTRY_PACKAGE_NAME}.`;
private indexDescriptions(descriptions: INodeTypeDescription[]): void {
this.descriptionsById.clear();
for (const description of descriptions) {
if (description.name.startsWith(prefix)) {
this.mcpRegistryDescriptions.set(description.name, description);
const existing = this.descriptionsById.get(description.name);
if (existing) {
existing.push(description);
} else {
this.descriptionsById.set(description.name, [description]);
}
}
}
/**
* Pick the description to synthesize from a node's versions. Honour an
* explicitly requested version (returning undefined when none matches, so
* the caller can report it), otherwise default to the latest (mirroring the
* on-disk lookup's default).
*/
private selectDescription(
candidates: INodeTypeDescription[],
requestedVersion?: string,
): INodeTypeDescription | undefined {
if (requestedVersion !== undefined) {
const wanted = Number.parseFloat(requestedVersion.replace(/^v/, ''));
return candidates.find((d) => nodeVersionNumbers(d).includes(wanted));
}
return candidates.reduce((latest, d) =>
maxNodeVersion(d) > maxNodeVersion(latest) ? d : latest,
);
}
private async resolveBuiltinNodeDefinitionDirs(): Promise<string[]> {
const dirs: string[] = [];
for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {
for (const packageId of BUILTIN_NODES_PACKAGES) {
try {
const packageJsonPath = require.resolve(`${packageId}/package.json`);
const distDir = path.dirname(packageJsonPath);
@@ -4,6 +4,7 @@ import { ChatPayload } from '@n8n/ai-workflow-builder/dist/workflow-builder-agen
import { Logger } from '@n8n/backend-common';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { GlobalConfig, SsrfProtectionConfig } from '@n8n/config';
import { BUILTIN_NODES_PACKAGES } from '@n8n/constants';
import { Service } from '@n8n/di';
import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
import * as fs from 'fs';
@@ -186,7 +187,7 @@ export class WorkflowBuilderService {
private resolveBuiltinNodeDefinitionDirs(): string[] {
const dirs: string[] = [];
for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {
for (const packageId of BUILTIN_NODES_PACKAGES) {
try {
const packageJsonPath = require.resolve(`${packageId}/package.json`);
const distDir = path.dirname(packageJsonPath);
@@ -73,6 +73,8 @@ export interface SetupProps {
enabledFeatures?: BooleanLicenseFeature[];
quotas?: Partial<{ [K in NumericLicenseFeature]: number }>;
modules?: ModuleName[];
/** Override the default Jest timeout (ms) for the shared `beforeAll` setup hook. */
setupTimeout?: number;
}
export type SuperAgentTest = TestAgent;
@@ -99,6 +99,7 @@ export const setupTestServer = ({
enabledFeatures,
quotas,
modules,
setupTimeout,
}: SetupProps): TestServer => {
const app = express();
app.use(rawBodyReader);
@@ -373,7 +374,7 @@ export const setupTestServer = ({
await Container.get(AuthHandlerRegistry).init();
}
});
}, setupTimeout);
afterAll(async () => {
// Close the HTTP server first so any in-flight requests can't reach the