mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 05:38:33 +08:00
feat(core): Synthesize type definitions for custom and community nodes in MCP (#32885)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user