Skip to content

Commit

Permalink
feat(core): Add an option to allow community nodes as tools
Browse files Browse the repository at this point in the history
  • Loading branch information
netroy committed Feb 5, 2025
1 parent 2a33d07 commit 3cc5df1
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 12 deletions.
4 changes: 4 additions & 0 deletions packages/@n8n/config/src/configs/nodes.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class CommunityPackagesConfig {
/** Whether to reinstall any missing community packages */
@Env('N8N_REINSTALL_MISSING_PACKAGES')
reinstallMissing: boolean = false;

/** Whether to allow community packages as tools for AI agents */
@Env('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE')
allowToolUsage: boolean = false;
}

@Config
Expand Down
1 change: 1 addition & 0 deletions packages/@n8n/config/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe('GlobalConfig', () => {
enabled: true,
registry: 'https://registry.npmjs.org',
reinstallMissing: false,
allowToolUsage: false,
},
errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [],
Expand Down
74 changes: 65 additions & 9 deletions packages/cli/src/__tests__/node-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import { RoutingNode, UnrecognizedNodeTypeError } from 'n8n-core';
import type {
Expand All @@ -11,11 +12,14 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';

describe('NodeTypes', () => {
const globalConfig = mock<GlobalConfig>({
nodes: { communityPackages: { allowToolUsage: false } },
});
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
convertNodeToAiTool: LoadNodesAndCredentials.prototype.convertNodeToAiTool,
});

const nodeTypes: NodeTypes = new NodeTypes(loadNodesAndCredentials);
const nodeTypes: NodeTypes = new NodeTypes(globalConfig, loadNodesAndCredentials);

const nonVersionedNode: LoadedClass<INodeType> = {
sourcePath: '',
Expand All @@ -24,10 +28,11 @@ describe('NodeTypes', () => {
name: 'n8n-nodes-base.nonVersioned',
usableAsTool: undefined,
}),
supplyData: undefined,
},
};
const v1Node = mock<INodeType>();
const v2Node = mock<INodeType>();
const v1Node = mock<INodeType>({ supplyData: undefined });
const v2Node = mock<INodeType>({ supplyData: undefined });
const versionedNode: LoadedClass<IVersionedNodeType> = {
sourcePath: '',
type: {
Expand All @@ -45,6 +50,17 @@ describe('NodeTypes', () => {
},
},
};
const toolNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: mock<INodeTypeDescription>({
name: 'n8n-nodes-base.toolNode',
displayName: 'TestNode',
properties: [],
}),
supplyData: jest.fn(),
},
};
const toolSupportingNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
Expand All @@ -54,6 +70,7 @@ describe('NodeTypes', () => {
usableAsTool: true,
properties: [],
}),
supplyData: undefined,
},
};
const declarativeNode: LoadedClass<INodeType> = {
Expand All @@ -70,20 +87,38 @@ describe('NodeTypes', () => {
trigger: undefined,
webhook: undefined,
methods: undefined,
supplyData: undefined,
},
};
const communityNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: mock<INodeTypeDescription>({
name: 'n8n-nodes-community.testNode',
displayName: 'TestNode',
usableAsTool: true,
properties: [],
}),
supplyData: undefined,
},
};

loadNodesAndCredentials.getNode.mockImplementation((fullNodeType) => {
const [packageName, nodeType] = fullNodeType.split('.');
if (nodeType === 'nonVersioned') return nonVersionedNode;
if (nodeType === 'versioned') return versionedNode;
if (nodeType === 'testNode') return toolSupportingNode;
if (nodeType === 'declarativeNode') return declarativeNode;
if (packageName === 'n8n-nodes-base') {
if (nodeType === 'nonVersioned') return nonVersionedNode;
if (nodeType === 'versioned') return versionedNode;
if (nodeType === 'testNode') return toolSupportingNode;
if (nodeType === 'declarativeNode') return declarativeNode;
if (nodeType === 'toolNode') return toolNode;
} else if (fullNodeType === 'n8n-nodes-community.testNode') return communityNode;
throw new UnrecognizedNodeTypeError(packageName, nodeType);
});

beforeEach(() => {
jest.clearAllMocks();
globalConfig.nodes.communityPackages.allowToolUsage = false;
loadNodesAndCredentials.loaded.nodes = {};
});

describe('getByName', () => {
Expand Down Expand Up @@ -122,6 +157,29 @@ describe('NodeTypes', () => {
);
});

it('should throw when a node-type is requested as tool, but the original node is already a tool', () => {
expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.toolNodeTool')).toThrow(
'Node already has a `supplyData` method',
);
});

it('should throw when a node-type is requested as tool, but is a community package', () => {
expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-community.testNodeTool')).toThrow(
'Unrecognized node type: n8n-nodes-community.testNodeTool',
);
});

it('should throw when a node-type is requested as tool, but is a community package', () => {
globalConfig.nodes.communityPackages.allowToolUsage = true;
const result = nodeTypes.getByNameAndVersion('n8n-nodes-community.testNodeTool');
expect(result).not.toEqual(toolSupportingNode.type);
expect(result.description.name).toEqual('n8n-nodes-community.testNodeTool');
expect(result.description.displayName).toEqual('TestNode Tool');
expect(result.description.codex?.categories).toContain('AI');
expect(result.description.inputs).toEqual([]);
expect(result.description.outputs).toEqual(['ai_tool']);
});

it('should return the tool node-type when requested as tool', () => {
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool');
expect(result).not.toEqual(toolSupportingNode.type);
Expand Down Expand Up @@ -184,7 +242,6 @@ describe('NodeTypes', () => {

describe('getNodeTypeDescriptions', () => {
it('should return descriptions for valid node types', () => {
const nodeTypes = new NodeTypes(loadNodesAndCredentials);
const result = nodeTypes.getNodeTypeDescriptions([
{ name: 'n8n-nodes-base.nonVersioned', version: 1 },
]);
Expand All @@ -194,7 +251,6 @@ describe('NodeTypes', () => {
});

it('should throw error for invalid node type', () => {
const nodeTypes = new NodeTypes(loadNodesAndCredentials);
expect(() =>
nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.nonExistent', version: 1 }]),
).toThrow('Unrecognized node type: n8n-nodes-base.nonExistent');
Expand Down
20 changes: 17 additions & 3 deletions packages/cli/src/node-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { NeededNodeType } from '@n8n/task-runner';
import type { Dirent } from 'fs';
Expand All @@ -12,7 +13,10 @@ import { LoadNodesAndCredentials } from './load-nodes-and-credentials';

@Service()
export class NodeTypes implements INodeTypes {
constructor(private loadNodesAndCredentials: LoadNodesAndCredentials) {}
constructor(
private readonly globalConfig: GlobalConfig,
private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
) {}

/**
* Variant of `getByNameAndVersion` that includes the node's source path, used to locate a node's translations.
Expand All @@ -33,14 +37,24 @@ export class NodeTypes implements INodeTypes {

getByNameAndVersion(nodeType: string, version?: number): INodeType {
const origType = nodeType;
const toolRequested = nodeType.startsWith('n8n-nodes-base') && nodeType.endsWith('Tool');

const { communityPackages } = this.globalConfig.nodes;
const allowToolUsage = communityPackages.allowToolUsage
? true
: nodeType.startsWith('n8n-nodes-base');
const toolRequested = nodeType.endsWith('Tool');

// Make sure the nodeType to actually get from disk is the un-wrapped type
if (toolRequested) {
if (allowToolUsage && toolRequested) {
nodeType = nodeType.replace(/Tool$/, '');
}

const node = this.loadNodesAndCredentials.getNode(nodeType);
const versionedNodeType = NodeHelpers.getVersionedNodeType(node.type, version);
if (typeof versionedNodeType.supplyData === 'function') {
throw new ApplicationError('Node already has a `supplyData` method', { extra: { nodeType } });
}

if (
!versionedNodeType.execute &&
!versionedNodeType.poll &&
Expand Down

0 comments on commit 3cc5df1

Please sign in to comment.