From c5689d44e2ffb49d28f5771e79b0737ae290dc12 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 29 Jun 2026 11:59:51 -0400 Subject: [PATCH] feat(core): Synthesize type definitions for custom and community nodes in MCP (#32885) --- packages/@n8n/constants/src/index.ts | 1 + packages/@n8n/constants/src/nodes.ts | 2 + .../instance-ai.adapter.service.ts | 4 +- .../instance-ai/node-definition-resolver.ts | 3 +- .../__tests__/synthesize-type-def.test.ts | 8 +- .../mcp-registry/synthesize-type-def.ts | 28 +-- ...ce.public-api-disabled.integration.test.ts | 4 +- .../__tests__/node-catalog.service.test.ts | 165 ++++++++++++++++++ .../src/node-catalog/node-catalog.service.ts | 119 ++++++++++--- .../services/ai-workflow-builder.service.ts | 3 +- packages/cli/test/integration/shared/types.ts | 2 + .../integration/shared/utils/test-server.ts | 3 +- 12 files changed, 299 insertions(+), 43 deletions(-) create mode 100644 packages/@n8n/constants/src/nodes.ts diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index b4eb19a2d27..33a4e472ab0 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -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', diff --git a/packages/@n8n/constants/src/nodes.ts b/packages/@n8n/constants/src/nodes.ts new file mode 100644 index 00000000000..4f76bc95be2 --- /dev/null +++ b/packages/@n8n/constants/src/nodes.ts @@ -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; 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 ea3533bc16f..c682aa23604 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 @@ -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 } : {}), }; } diff --git a/packages/cli/src/modules/instance-ai/node-definition-resolver.ts b/packages/cli/src/modules/instance-ai/node-definition-resolver.ts index 9c316d65112..495d2778c04 100644 --- a/packages/cli/src/modules/instance-ai/node-definition-resolver.ts +++ b/packages/cli/src/modules/instance-ai/node-definition-resolver.ts @@ -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); 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 index a9cc9e4c2a3..ba04d547578 100644 --- 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 @@ -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'); diff --git a/packages/cli/src/modules/mcp-registry/synthesize-type-def.ts b/packages/cli/src/modules/mcp-registry/synthesize-type-def.ts index 53eece25077..a5004b54456 100644 --- a/packages/cli/src/modules/mcp-registry/synthesize-type-def.ts +++ b/packages/cli/src/modules/mcp-registry/synthesize-type-def.ts @@ -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); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.public-api-disabled.integration.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.public-api-disabled.integration.test.ts index 6a080ea519c..ae7345460c0 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.public-api-disabled.integration.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.public-api-disabled.integration.test.ts @@ -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 () => { diff --git a/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts b/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts index 892f8f539d2..2e829302d57 100644 --- a/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts +++ b/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts @@ -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', () => { diff --git a/packages/cli/src/node-catalog/node-catalog.service.ts b/packages/cli/src/node-catalog/node-catalog.service.ts index 16cdb1a3412..2836ed08e48 100644 --- a/packages/cli/src/node-catalog/node-catalog.service.ts +++ b/packages/cli/src/node-catalog/node-catalog.service.ts @@ -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(); + private descriptionsById = new Map(); private initPromise: Promise | 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 { 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); diff --git a/packages/cli/src/services/ai-workflow-builder.service.ts b/packages/cli/src/services/ai-workflow-builder.service.ts index d887e60975f..74c0e7630a9 100644 --- a/packages/cli/src/services/ai-workflow-builder.service.ts +++ b/packages/cli/src/services/ai-workflow-builder.service.ts @@ -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); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index a3f5f9c74a7..03962437cca 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -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; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 79a6cba900d..8671086d620 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -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