feat(core): Enable n8n Connect usage in n8n MCP (#33723)

This commit is contained in:
Michael Kret
2026-07-15 11:58:44 +00:00
committed by GitHub
parent cec46445ee
commit 4e4283bf62
33 changed files with 2181 additions and 88 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ export { AiSessionRetrievalRequestDto } from './ai/ai-session-retrieval-request.
export { AiUsageSettingsRequestDto } from './ai/ai-usage-settings-request.dto';
export { AiTruncateMessagesRequestDto } from './ai/ai-truncate-messages-request.dto';
export { AiClearSessionRequestDto } from './ai/ai-clear-session-request.dto';
export type {
export {
AiGatewayConfigDto,
AiGatewayProviderConfigEntry,
} from './ai/ai-gateway-config-response.dto';
@@ -37,7 +37,7 @@ describe('resolveCredentialForApply', () => {
expect(result).toEqual({
resolved: false,
error: 'Credential type "openAiApi" is not supported by AI Gateway',
error: 'Credential type "openAiApi" is not supported by n8n Connect',
});
});
@@ -69,7 +69,7 @@ export async function resolveCredentialForApply(
if (!supported) {
return {
resolved: false,
error: `Credential type "${credType}" is not supported by AI Gateway`,
error: `Credential type "${credType}" is not supported by n8n Connect`,
};
}
}
+14
View File
@@ -7,8 +7,22 @@ import {
isWorkflowIdValid,
setMicrosoftObservabilityDefaults,
containsExpression,
stripToolSuffix,
} from '../utils';
describe('stripToolSuffix', () => {
it.each([
['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.openAi'],
['@n8n/n8n-nodes-langchain.openAiTool', '@n8n/n8n-nodes-langchain.openAi'],
['@n8n/n8n-nodes-langchain.openAiHitlTool', '@n8n/n8n-nodes-langchain.openAi'],
['@n8n/n8n-nodes-langchain.slackTool', '@n8n/n8n-nodes-langchain.slack'],
['plain', 'plain'],
['n8n-nodes-base.set', 'n8n-nodes-base.set'],
])('strips %s -> %s', (input, expected) => {
expect(stripToolSuffix(input)).toBe(expected);
});
});
describe('shouldAssignExecuteMethod', () => {
it('should return true when node has no execute, poll, trigger, webhook (unless declarative), or methods', () => {
const nodeType = {
@@ -0,0 +1,283 @@
import type { AiGatewayConfigDto } from '@n8n/api-types';
import type { INode } from 'n8n-workflow';
import { checkAiGatewayEligibility } from '../tools/workflow-builder/ai-gateway-eligibility';
function makeNode(overrides: Partial<INode> = {}): INode {
return {
id: 'n1',
name: 'Test',
type: '@n8n/n8n-nodes-langchain.openAi',
typeVersion: 1,
position: [0, 0],
parameters: {},
...overrides,
};
}
function makeConfig(overrides: Partial<AiGatewayConfigDto> = {}): AiGatewayConfigDto {
return {
nodes: ['@n8n/n8n-nodes-langchain.openAi'],
credentialTypes: ['openAiApi'],
providerConfig: {
openAiApi: { gatewayPath: '/v1/gateway/openai/v1', urlField: 'url', apiKeyField: 'apiKey' },
},
...overrides,
} as AiGatewayConfigDto;
}
describe('checkAiGatewayEligibility', () => {
describe('node coverage', () => {
it('returns nodeNotCovered when nodeType not in config.nodes (and stripped form also missing)', () => {
const result = checkAiGatewayEligibility(
makeNode({ type: 'n8n-nodes-base.slack' }),
'slackApi',
makeConfig(),
);
expect(result).toEqual({ eligible: false, reason: 'nodeNotCovered' });
});
it('accepts bare node type present in config.nodes', () => {
const result = checkAiGatewayEligibility(makeNode(), 'openAiApi', makeConfig());
expect(result).toEqual({ eligible: true });
});
it('accepts a node when only its tool-suffix-stripped form is in config.nodes', () => {
const result = checkAiGatewayEligibility(
makeNode({ type: '@n8n/n8n-nodes-langchain.openAiTool' }),
'openAiApi',
makeConfig(),
);
expect(result).toEqual({ eligible: true });
});
});
describe('credential type coverage', () => {
it('returns credentialTypeNotCovered when credentialType is not in config.credentialTypes', () => {
const result = checkAiGatewayEligibility(
makeNode(),
'openAiOAuth2',
makeConfig({ credentialTypes: ['openAiApi'] }),
);
expect(result).toEqual({ eligible: false, reason: 'credentialTypeNotCovered' });
});
});
describe('version floor', () => {
it('returns versionTooLow when typeVersion is below minNodeTypeVersion', () => {
const result = checkAiGatewayEligibility(
makeNode({ typeVersion: 1 }),
'openAiApi',
makeConfig({
minNodeTypeVersion: { '@n8n/n8n-nodes-langchain.openAi': 1.2 },
}),
);
expect(result).toMatchObject({ eligible: false, reason: 'versionTooLow' });
});
it('passes when typeVersion equals minNodeTypeVersion', () => {
const result = checkAiGatewayEligibility(
makeNode({ typeVersion: 1.2 }),
'openAiApi',
makeConfig({
minNodeTypeVersion: { '@n8n/n8n-nodes-langchain.openAi': 1.2 },
}),
);
expect(result).toEqual({ eligible: true });
});
it('passes when no minNodeTypeVersion entry for this node', () => {
const result = checkAiGatewayEligibility(
makeNode({ typeVersion: 1 }),
'openAiApi',
makeConfig(),
);
expect(result).toEqual({ eligible: true });
});
it('uses stripToolSuffix fallback for minNodeTypeVersion lookup', () => {
const result = checkAiGatewayEligibility(
makeNode({ type: '@n8n/n8n-nodes-langchain.openAiTool', typeVersion: 1 }),
'openAiApi',
makeConfig({
minNodeTypeVersion: { '@n8n/n8n-nodes-langchain.openAi': 1.2 },
}),
);
expect(result).toMatchObject({ eligible: false, reason: 'versionTooLow' });
});
});
describe('hidden properties', () => {
it('returns hiddenPropertySet when a listed hidden property is set on the node', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: { baseURL: 'https://custom.example.com' } }),
'openAiApi',
makeConfig({
hiddenNodeProperties: { '@n8n/n8n-nodes-langchain.openAi': ['baseURL'] },
}),
);
expect(result).toMatchObject({ eligible: false, reason: 'hiddenPropertySet' });
});
it('passes when hidden properties list exists but none are set on the node', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: { model: 'gpt-4' } }),
'openAiApi',
makeConfig({
hiddenNodeProperties: { '@n8n/n8n-nodes-langchain.openAi': ['baseURL'] },
}),
);
expect(result).toEqual({ eligible: true });
});
it('does not disqualify when a hidden property is only present via resolvedParameters (default)', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: {} }),
'openAiApi',
makeConfig({
hiddenNodeProperties: { '@n8n/n8n-nodes-langchain.openAi': ['baseURL'] },
}),
{ baseURL: 'https://api.openai.com/v1' },
);
expect(result).toEqual({ eligible: true });
});
it('returns hiddenPropertySet when a hidden property is nested inside a collection', () => {
const result = checkAiGatewayEligibility(
makeNode({
type: 'n8n-nodes-browserbase.browserbase',
parameters: { modelOptions: { modelSource: 'openai' } },
}),
'browserbaseApi',
makeConfig({
nodes: ['n8n-nodes-browserbase.browserbase'],
credentialTypes: ['browserbaseApi'],
hiddenNodeProperties: { 'n8n-nodes-browserbase.browserbase': ['modelSource'] },
}),
);
expect(result).toMatchObject({ eligible: false, reason: 'hiddenPropertySet' });
});
});
describe('supportedActions (with resource/operation)', () => {
const supportedActions = {
'@n8n/n8n-nodes-langchain.openAi': {
text: ['message', 'response'],
image: ['generate'],
},
};
it('passes when resource+operation is in the allowlist', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: { resource: 'text', operation: 'message' } }),
'openAiApi',
makeConfig({ supportedActions }),
);
expect(result).toEqual({ eligible: true });
});
it('returns unsupportedAction when operation is not in the resource allowlist', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: { resource: 'text', operation: 'classify' } }),
'openAiApi',
makeConfig({ supportedActions }),
);
expect(result).toMatchObject({ eligible: false, reason: 'unsupportedAction' });
});
it('returns unsupportedAction when the resource is not in the actions map', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: { resource: 'audio', operation: 'transcribe' } }),
'openAiApi',
makeConfig({ supportedActions }),
);
expect(result).toMatchObject({ eligible: false, reason: 'unsupportedAction' });
});
it('returns unsupportedAction when operation is missing but the resource has an allowlist', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: { resource: 'text' } }),
'openAiApi',
makeConfig({ supportedActions }),
);
expect(result).toMatchObject({ eligible: false, reason: 'unsupportedAction' });
});
it('reads resource/operation from resolvedParameters (defaults) when the node omits them', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: {} }),
'openAiApi',
makeConfig({ supportedActions }),
{ resource: 'text', operation: 'message' },
);
expect(result).toEqual({ eligible: true });
});
it('returns unsupportedAction when the defaulted action is not in the allowlist', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: {} }),
'openAiApi',
makeConfig({ supportedActions }),
{ resource: 'text', operation: 'classify' },
);
expect(result).toMatchObject({ eligible: false, reason: 'unsupportedAction' });
});
it('passes when no supportedActions entry exists for this node (missing = no filter)', () => {
const result = checkAiGatewayEligibility(
makeNode({ parameters: { resource: 'text', operation: 'message' } }),
'openAiApi',
makeConfig(),
);
expect(result).toEqual({ eligible: true });
});
});
describe('supportedActions (flat __operation_only__ sentinel)', () => {
const supportedActions = {
'n8n-nodes-brave.braveSearch': {
__operation_only__: ['webSearch', 'imageSearch'],
},
};
it('passes when operation is in the flat allowlist', () => {
const result = checkAiGatewayEligibility(
makeNode({ type: 'n8n-nodes-brave.braveSearch', parameters: { operation: 'webSearch' } }),
'braveSearchApi',
makeConfig({
nodes: ['n8n-nodes-brave.braveSearch'],
credentialTypes: ['braveSearchApi'],
supportedActions,
}),
);
expect(result).toEqual({ eligible: true });
});
it('reads operation from resolvedParameters (defaults) for operation-only nodes', () => {
const result = checkAiGatewayEligibility(
makeNode({ type: 'n8n-nodes-brave.braveSearch', parameters: {} }),
'braveSearchApi',
makeConfig({
nodes: ['n8n-nodes-brave.braveSearch'],
credentialTypes: ['braveSearchApi'],
supportedActions,
}),
{ operation: 'webSearch' },
);
expect(result).toEqual({ eligible: true });
});
it('returns unsupportedAction when operation is not in the flat allowlist', () => {
const result = checkAiGatewayEligibility(
makeNode({ type: 'n8n-nodes-brave.braveSearch', parameters: { operation: 'newsSearch' } }),
'braveSearchApi',
makeConfig({
nodes: ['n8n-nodes-brave.braveSearch'],
credentialTypes: ['braveSearchApi'],
supportedActions,
}),
);
expect(result).toMatchObject({ eligible: false, reason: 'unsupportedAction' });
});
});
});
@@ -1,12 +1,14 @@
import type { Mock } from 'vitest';
import { mockInstance } from '@n8n/backend-test-utils';
import { ProjectRepository, User, WorkflowEntity } from '@n8n/db';
import { NodeConnectionTypes, type INode } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { z } from 'zod';
import { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { NodeTypes } from '@/node-types';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import { UrlService } from '@/services/url.service';
import { Telemetry } from '@/telemetry';
import { WorkflowCreationService } from '@/workflows/workflow-creation.service';
@@ -16,18 +18,24 @@ import { createCreateWorkflowFromCodeTool } from '../tools/workflow-builder/crea
// Mocks referenced inside vi.mock factories must come from vi.hoisted, otherwise the
// factory (hoisted above these declarations) silently loads the real module.
const { mockAutoPopulateNodeCredentials, mockParseAndValidate, mockStripImportStatements } =
vi.hoisted(() => ({
mockAutoPopulateNodeCredentials: vi.fn(),
mockParseAndValidate: vi.fn(),
mockStripImportStatements: vi.fn((code: string) => code),
}));
const {
mockAutoPopulateNodeCredentials,
mockTrackAutoassignOutcomes,
mockParseAndValidate,
mockStripImportStatements,
} = vi.hoisted(() => ({
mockAutoPopulateNodeCredentials: vi.fn(),
mockTrackAutoassignOutcomes: vi.fn(),
mockParseAndValidate: vi.fn(),
mockStripImportStatements: vi.fn((code: string) => code),
}));
// Mock credentials auto-assign
vi.mock('../tools/workflow-builder/credentials-auto-assign', () => ({
autoPopulateNodeCredentials: (...args: unknown[]) =>
mockAutoPopulateNodeCredentials(...args) as unknown,
stripNullCredentialStubs: vi.fn(),
trackAutoassignOutcomes: (...args: unknown[]) => mockTrackAutoassignOutcomes(...args) as unknown,
}));
// Mock dynamic imports
@@ -123,7 +131,11 @@ describe('create-workflow-from-code MCP tool', () => {
mockParseAndValidate.mockResolvedValue({ workflow: mockWorkflowJson, warnings: [] });
mockStripImportStatements.mockImplementation((code: string) => code);
mockAutoPopulateNodeCredentials.mockResolvedValue({ assignments: [], skippedHttpNodes: [] });
mockAutoPopulateNodeCredentials.mockResolvedValue({
assignments: [],
skippedHttpNodes: [],
outcomes: [],
});
dataTableOps = {
getManyAndCount: vi.fn().mockResolvedValue({ data: [], count: 0 }),
@@ -152,6 +164,9 @@ describe('create-workflow-from-code MCP tool', () => {
findWorkflowForUser: vi.fn().mockResolvedValue(null),
});
const aiGatewayService = mock<AiGatewayService>();
aiGatewayService.isAvailable.mockResolvedValue({ available: false });
const createTool = () =>
createCreateWorkflowFromCodeTool(
user,
@@ -163,6 +178,7 @@ describe('create-workflow-from-code MCP tool', () => {
credentialsService,
projectRepository,
dataTableOps as never,
aiGatewayService,
);
// Helper to call handler with proper typing (optional fields default to undefined)
@@ -786,6 +802,37 @@ describe('create-workflow-from-code MCP tool', () => {
});
});
test('tracks auto-assign outcomes with the persisted workflow id after save', async () => {
mockAutoPopulateNodeCredentials.mockResolvedValue({
assignments: [],
skippedHttpNodes: [],
outcomes: [
{
nodeName: 'OpenAI',
credentialType: 'openAiApi',
source: 'aiGateway',
hadUserCredential: false,
aiGatewayAvailable: true,
},
],
});
await callHandler({ code: 'const wf = ...' });
expect(mockTrackAutoassignOutcomes).toHaveBeenCalledTimes(1);
const trackArgs = mockTrackAutoassignOutcomes.mock.calls[0];
expect(trackArgs[2]).toBe('create_workflow_from_code');
expect(trackArgs[5]).toBe('wf-saved-1');
});
test('does not track auto-assign outcomes when the save fails', async () => {
createWorkflowMock.mockRejectedValueOnce(new Error('save failed'));
await callHandler({ code: 'const wf = ...' });
expect(mockTrackAutoassignOutcomes).not.toHaveBeenCalled();
});
test('refuses to save when an agent is wired as a tool to another agent', async () => {
mockParseAndValidate.mockResolvedValue({
workflow: {
@@ -832,9 +879,21 @@ describe('create-workflow-from-code MCP tool', () => {
// so any field returned by the handler but missing from the schema breaks strict clients.
mockAutoPopulateNodeCredentials.mockResolvedValue({
assignments: [
{ nodeName: 'Webhook', credentialName: 'My Cred', credentialType: 'webhookAuth' },
{
nodeName: 'Webhook',
credentialName: 'My Cred',
credentialType: 'webhookAuth',
source: 'user',
},
{
nodeName: 'OpenAI',
credentialName: 'n8n credits',
credentialType: 'openAiApi',
source: 'aiGateway',
},
],
skippedHttpNodes: [],
outcomes: [],
});
const tool = createTool();
@@ -9,8 +9,13 @@ import { NodeHelpers } from 'n8n-workflow';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { NodeTypes } from '@/node-types';
import type { Telemetry } from '@/telemetry';
import { autoPopulateNodeCredentials } from '../tools/workflow-builder/credentials-auto-assign';
import {
autoPopulateNodeCredentials,
trackAutoassignOutcomes,
type SlotOutcome,
} from '../tools/workflow-builder/credentials-auto-assign';
const user = { id: 'user-1' } as User;
const projectId = 'project-1';
@@ -100,7 +105,12 @@ describe('autoPopulateNodeCredentials', () => {
);
expect(result.assignments).toEqual([
{ nodeName: 'Test Node', credentialName: 'My Slack Token', credentialType: 'slackApi' },
{
nodeName: 'Test Node',
credentialName: 'My Slack Token',
credentialType: 'slackApi',
source: 'user',
},
]);
expect(node.credentials).toEqual({ slackApi: { id: 'cred-1', name: 'My Slack Token' } });
expect(result.skippedHttpNodes).toEqual([]);
@@ -323,8 +333,18 @@ describe('autoPopulateNodeCredentials', () => {
);
expect(result.assignments).toEqual([
{ nodeName: 'Slack', credentialName: 'My Slack', credentialType: 'slackApi' },
{ nodeName: 'Gmail', credentialName: 'My Gmail', credentialType: 'gmailOAuth2' },
{
nodeName: 'Slack',
credentialName: 'My Slack',
credentialType: 'slackApi',
source: 'user',
},
{
nodeName: 'Gmail',
credentialName: 'My Gmail',
credentialType: 'gmailOAuth2',
source: 'user',
},
]);
});
@@ -338,4 +358,479 @@ describe('autoPopulateNodeCredentials', () => {
projectId,
});
});
describe('AI Gateway fallback', () => {
const gatewayConfig = {
nodes: ['n8n-nodes-base.slack'],
credentialTypes: ['slackApi'],
providerConfig: {
slackApi: { gatewayPath: '/v1/gateway/slack', urlField: 'url', apiKeyField: 'apiKey' },
},
} as const;
function makeAiGatewayService(available: boolean) {
const isAvailable = vi
.fn()
.mockResolvedValue(
available ? { available: true, config: gatewayConfig } : { available: false },
);
return { isAvailable } as unknown as import('@/services/ai-gateway.service').AiGatewayService;
}
test('attaches the AI Gateway sentinel when user has no cred and gateway is eligible', async () => {
const node = makeNode();
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription();
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(true),
);
expect(node.credentials).toEqual({
slackApi: { id: null, name: 'n8n credits', __aiGatewayManaged: true },
});
expect(result.assignments).toEqual([
{
nodeName: 'Test Node',
credentialName: 'n8n credits',
credentialType: 'slackApi',
source: 'aiGateway',
},
]);
expect(result.outcomes).toEqual([
{
nodeName: 'Test Node',
credentialType: 'slackApi',
source: 'aiGateway',
hadUserCredential: false,
aiGatewayAvailable: true,
},
]);
});
test('prefers user credential when both exist', async () => {
const node = makeNode();
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription();
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [{ id: 'cred-1', name: 'My Slack', type: 'slackApi' }],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(true),
);
expect(node.credentials).toEqual({ slackApi: { id: 'cred-1', name: 'My Slack' } });
expect(result.outcomes).toEqual([
{
nodeName: 'Test Node',
credentialType: 'slackApi',
source: 'user',
hadUserCredential: true,
aiGatewayAvailable: true,
},
]);
});
test('leaves slot empty and records reasonNotAiGateway when gateway unavailable', async () => {
const node = makeNode();
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription();
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(false),
);
expect(node.credentials).toBeUndefined();
expect(result.outcomes).toEqual([
{
nodeName: 'Test Node',
credentialType: 'slackApi',
source: 'none',
hadUserCredential: false,
aiGatewayAvailable: false,
reasonNotAiGateway: 'notAvailable',
},
]);
});
test('leaves slot empty when gateway is available but node is not covered', async () => {
const node = makeNode({ type: 'n8n-nodes-base.gmail' });
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription({
name: 'n8n-nodes-base.gmail',
credentials: [makeCredentialDescription({ name: 'gmailOAuth2' })],
});
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.gmail', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(true),
);
expect(node.credentials).toBeUndefined();
expect(result.outcomes).toEqual([
{
nodeName: 'Test Node',
credentialType: 'gmailOAuth2',
source: 'none',
hadUserCredential: false,
aiGatewayAvailable: true,
reasonNotAiGateway: 'nodeNotCovered',
},
]);
});
test('isAvailable() returning unavailable falls through to empty slot', async () => {
const node = makeNode();
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription();
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const aiGatewayService = {
isAvailable: vi.fn().mockResolvedValue({ available: false }),
} as unknown as import('@/services/ai-gateway.service').AiGatewayService;
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
aiGatewayService,
);
expect(node.credentials).toBeUndefined();
expect(result.outcomes[0]).toMatchObject({
source: 'none',
aiGatewayAvailable: false,
reasonNotAiGateway: 'notAvailable',
});
});
test('legacy call (no aiGatewayService) preserves pre-change behavior', async () => {
const node = makeNode();
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription();
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
);
expect(node.credentials).toBeUndefined();
expect(result.assignments).toEqual([]);
expect(result.outcomes).toEqual([
{
nodeName: 'Test Node',
credentialType: 'slackApi',
source: 'none',
hadUserCredential: false,
aiGatewayAvailable: false,
reasonNotAiGateway: 'notAvailable',
},
]);
});
describe('incoming n8n Connect markers', () => {
const suppliedMarker = { id: null, name: 'supplied name', __aiGatewayManaged: true } as const;
test('keeps an eligible incoming marker and honors it over the user credential', async () => {
const node = makeNode({ credentials: { slackApi: { ...suppliedMarker } } });
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription();
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [{ id: 'cred-1', name: 'My Slack', type: 'slackApi' }],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(true),
);
// Canonicalized to the sentinel and kept — the explicit n8n Connect request
// wins over the owned credential, and no assignment is recorded for it.
expect(node.credentials).toEqual({
slackApi: { id: null, name: 'n8n credits', __aiGatewayManaged: true },
});
expect(result.assignments).toEqual([]);
expect(result.outcomes).toEqual([]);
});
test('strips an ineligible incoming marker when the node is not covered', async () => {
const node = makeNode({
type: 'n8n-nodes-base.gmail',
credentials: { gmailOAuth2: { ...suppliedMarker } },
});
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription({
name: 'n8n-nodes-base.gmail',
credentials: [makeCredentialDescription({ name: 'gmailOAuth2' })],
});
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.gmail', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(true),
);
expect(node.credentials?.gmailOAuth2).toBeUndefined();
expect(result.outcomes[0]).toMatchObject({
source: 'none',
reasonNotAiGateway: 'nodeNotCovered',
});
});
test('strips a marker on an HTTP Request node', async () => {
const node = makeNode({
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
credentials: { slackApi: { ...suppliedMarker } },
});
const workflow = makeWorkflow([node]);
const { credentialsService, nodeTypes } = createMocks({ usableCredentials: [] });
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(true),
);
expect(node.credentials?.slackApi).toBeUndefined();
expect(result.skippedHttpNodes).toEqual(['HTTP']);
});
test('strips a marker placed under an undeclared credential key', async () => {
const node = makeNode({
credentials: {
slackApi: { id: 'cred-1', name: 'My Slack' },
bogusApi: { ...suppliedMarker },
},
});
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription();
const { credentialsService, nodeTypes } = createMocks({
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(true),
);
expect(node.credentials?.bogusApi).toBeUndefined();
// The explicit credential id on the declared slot is left untouched.
expect(node.credentials?.slackApi).toEqual({ id: 'cred-1', name: 'My Slack' });
});
test('strips an incoming marker when the gateway is unavailable', async () => {
const node = makeNode({ credentials: { slackApi: { ...suppliedMarker } } });
const workflow = makeWorkflow([node]);
const desc = makeNodeTypeDescription();
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', desc]]),
});
vi.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
const result = await autoPopulateNodeCredentials(
workflow,
user,
nodeTypes,
credentialsService,
projectId,
makeAiGatewayService(false),
);
expect(node.credentials?.slackApi).toBeUndefined();
expect(result.outcomes[0]).toMatchObject({
source: 'none',
reasonNotAiGateway: 'notAvailable',
});
});
});
});
});
describe('trackAutoassignOutcomes', () => {
const makeTelemetry = () => ({ track: vi.fn() }) as unknown as Telemetry;
const gatewayOutcome: SlotOutcome = {
nodeName: 'Slack',
credentialType: 'slackApi',
source: 'aiGateway',
hadUserCredential: false,
aiGatewayAvailable: true,
};
const userOutcome: SlotOutcome = {
nodeName: 'Gmail',
credentialType: 'gmailOAuth2',
source: 'user',
hadUserCredential: true,
aiGatewayAvailable: true,
};
const noneOutcome: SlotOutcome = {
nodeName: 'HTTP',
credentialType: 'httpBasicAuth',
source: 'none',
hadUserCredential: false,
aiGatewayAvailable: false,
reasonNotAiGateway: 'notAvailable',
};
it("emits 'Node credential assigned' with source mcp and kind n8n_connect for a gateway slot", () => {
const telemetry = makeTelemetry();
trackAutoassignOutcomes(
telemetry,
'user-1',
'update_workflow',
[gatewayOutcome],
undefined,
'wf-1',
);
expect(telemetry.track).toHaveBeenCalledWith('Node credential assigned', {
credential_type: 'slackApi',
node_type: 'Slack',
workflow_id: 'wf-1',
credential_kind: 'n8n_connect',
source: 'mcp',
});
});
it('maps a user-credential slot to credential_kind own', () => {
const telemetry = makeTelemetry();
trackAutoassignOutcomes(
telemetry,
'user-1',
'update_workflow',
[userOutcome],
undefined,
'wf-1',
);
expect(telemetry.track).toHaveBeenCalledWith(
'Node credential assigned',
expect.objectContaining({ credential_kind: 'own', source: 'mcp' }),
);
});
it("does not emit 'Node credential assigned' for an unfilled slot", () => {
const telemetry = makeTelemetry();
trackAutoassignOutcomes(telemetry, 'user-1', 'create_workflow_from_code', [noneOutcome]);
expect(telemetry.track).not.toHaveBeenCalledWith('Node credential assigned', expect.anything());
});
it('resolves node_type from the map and defaults workflow_id to empty when omitted', () => {
const telemetry = makeTelemetry();
const nodesByName = new Map([['Slack', 'n8n-nodes-base.slack']]);
trackAutoassignOutcomes(
telemetry,
'user-1',
'create_workflow_from_code',
[gatewayOutcome],
nodesByName,
);
expect(telemetry.track).toHaveBeenCalledWith(
'Node credential assigned',
expect.objectContaining({ node_type: 'n8n-nodes-base.slack', workflow_id: '' }),
);
});
it('still emits the MCP-specific detail event for every outcome', () => {
const telemetry = makeTelemetry();
trackAutoassignOutcomes(
telemetry,
'user-1',
'update_workflow',
[gatewayOutcome, noneOutcome],
undefined,
'wf-1',
);
expect(telemetry.track).toHaveBeenCalledWith(
'MCP credentials autoassign',
expect.objectContaining({ source: 'aiGateway' }),
);
expect(telemetry.track).toHaveBeenCalledWith(
'MCP credentials autoassign',
expect.objectContaining({ source: 'none', reason_not_ai_gateway: 'notAvailable' }),
);
});
});
@@ -0,0 +1,91 @@
import type { AiGatewayConfigDto } from '@n8n/api-types';
import { User } from '@n8n/db';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { NodeCatalogService } from '@/node-catalog';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { Telemetry } from '@/telemetry';
import { createGetWorkflowNodeTypesTool } from '../tools/workflow-builder/get-workflow-node-types.tool';
vi.mock('@n8n/ai-workflow-builder', () => ({
CODE_BUILDER_GET_NODE_TYPES_TOOL: {
toolName: 'get_workflow_node_types',
displayTitle: 'Get workflow node types',
},
CODE_BUILDER_SEARCH_NODES_TOOL: { toolName: 'search', displayTitle: 'Search' },
CODE_BUILDER_GET_SUGGESTED_NODES_TOOL: { toolName: 'suggest', displayTitle: 'Suggest' },
CODE_BUILDER_VALIDATE_TOOL: { toolName: 'validate', displayTitle: 'Validate' },
MCP_GET_SDK_REFERENCE_TOOL: { toolName: 'sdk_ref', displayTitle: 'SDK Ref' },
MCP_CREATE_WORKFLOW_FROM_CODE_TOOL: { toolName: 'create', displayTitle: 'Create' },
MCP_ARCHIVE_WORKFLOW_TOOL: { toolName: 'archive', displayTitle: 'Archive' },
MCP_UPDATE_WORKFLOW_TOOL: { toolName: 'update', displayTitle: 'Update' },
}));
describe('get-workflow-node-types MCP tool', () => {
const user = Object.assign(new User(), { id: 'user-1' });
let nodeCatalogService: Mocked<NodeCatalogService>;
let telemetry: Mocked<Telemetry>;
let aiGatewayService: Mocked<AiGatewayService>;
beforeEach(() => {
vi.clearAllMocks();
nodeCatalogService = mock<NodeCatalogService>();
telemetry = mock<Telemetry>();
aiGatewayService = mock<AiGatewayService>();
aiGatewayService.isAvailable.mockResolvedValue({ available: false });
nodeCatalogService.getNodeTypes.mockResolvedValue('typescript definitions');
});
const createTool = () =>
createGetWorkflowNodeTypesTool(user, nodeCatalogService, telemetry, aiGatewayService);
test('returns definitions verbatim', async () => {
const tool = createTool();
const result = await tool.handler(
{ nodeIds: [{ nodeId: 'n8n-nodes-base.gmail' }] },
{} as never,
);
expect(result.structuredContent).toEqual({ definitions: 'typescript definitions' });
});
test('adds n8nConnect block when gateway is available', async () => {
aiGatewayService.isAvailable.mockResolvedValue({
available: true,
config: {
nodes: ['@n8n/n8n-nodes-langchain.openAi'],
credentialTypes: ['openAiApi'],
providerConfig: {},
} as AiGatewayConfigDto,
});
const tool = createTool();
const result = await tool.handler(
{ nodeIds: [{ nodeId: '@n8n/n8n-nodes-langchain.openAi' }] },
{} as never,
);
expect(result.structuredContent).toEqual({
definitions: 'typescript definitions',
n8nConnect: {
credentialTypes: ['openAiApi'],
nodes: ['@n8n/n8n-nodes-langchain.openAi'],
},
});
// Also mirrored into the unstructured content for text-only clients.
expect((result.content[0] as { text: string }).text).toBe(
'typescript definitions\n\nn8nConnect: {"credentialTypes":["openAiApi"],"nodes":["@n8n/n8n-nodes-langchain.openAi"]}',
);
});
test('omits n8nConnect block when unavailable', async () => {
const tool = createTool();
const result = await tool.handler(
{ nodeIds: [{ nodeId: 'n8n-nodes-base.slack' }] },
{} as never,
);
expect(result.structuredContent).toEqual({ definitions: 'typescript definitions' });
expect((result.content[0] as { text: string }).text).toBe('typescript definitions');
});
});
@@ -1,9 +1,12 @@
import type { AiGatewayConfigDto } from '@n8n/api-types';
import { mockInstance } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import type { CredentialsEntity } from '@n8n/db';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CredentialsService } from '@/credentials/credentials.service';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import { Telemetry } from '@/telemetry';
import { createListCredentialsTool, listCredentials } from '../tools/list-credentials.tool';
@@ -37,11 +40,41 @@ describe('list-credentials MCP tool', () => {
return { credentialsService, telemetry };
};
const makeAiGatewayMocks = (
opts: {
available?: boolean;
config?: Partial<AiGatewayConfigDto>;
} = {},
) => {
const aiGatewayService = mock<AiGatewayService>();
if (opts.available === false) {
aiGatewayService.isAvailable.mockResolvedValue({ available: false });
} else {
aiGatewayService.isAvailable.mockResolvedValue({
available: true,
config: {
nodes: ['@n8n/n8n-nodes-langchain.openAi'],
credentialTypes: ['openAiApi'],
providerConfig: {
openAiApi: {
gatewayPath: '/v1/gateway/openai/v1',
urlField: 'url',
apiKeyField: 'apiKey',
},
},
...opts.config,
} as AiGatewayConfigDto,
});
}
return { aiGatewayService };
};
describe('smoke tests', () => {
test('creates the tool correctly', () => {
const { credentialsService, telemetry } = createMocks();
const { aiGatewayService } = makeAiGatewayMocks();
const tool = createListCredentialsTool(user, credentialsService, telemetry);
const tool = createListCredentialsTool(user, credentialsService, telemetry, aiGatewayService);
expect(tool.name).toBe('list_credentials');
expect(tool.config.description).toEqual(expect.any(String));
@@ -188,7 +221,8 @@ describe('list-credentials MCP tool', () => {
test('tracks telemetry on success', async () => {
const { credentialsService, telemetry } = createMocks([buildCredential()]);
const tool = createListCredentialsTool(user, credentialsService, telemetry);
const { aiGatewayService } = makeAiGatewayMocks({ available: false });
const tool = createListCredentialsTool(user, credentialsService, telemetry, aiGatewayService);
await tool.handler(
{
limit: undefined as unknown as number,
@@ -213,7 +247,8 @@ describe('list-credentials MCP tool', () => {
test('returns isError and tracks failure when service throws', async () => {
const { credentialsService, telemetry } = createMocks(new Error('DB exploded'));
const tool = createListCredentialsTool(user, credentialsService, telemetry);
const { aiGatewayService } = makeAiGatewayMocks({ available: false });
const tool = createListCredentialsTool(user, credentialsService, telemetry, aiGatewayService);
const result = await tool.handler(
{
limit: undefined as unknown as number,
@@ -239,5 +274,46 @@ describe('list-credentials MCP tool', () => {
}),
);
});
describe('n8nConnect block', () => {
async function callHandler(opts: { available?: boolean } = {}) {
const { credentialsService, telemetry } = createMocks([buildCredential()]);
const { aiGatewayService } = makeAiGatewayMocks(opts);
const tool = createListCredentialsTool(
user,
credentialsService,
telemetry,
aiGatewayService,
);
const result = await tool.handler(
{
limit: undefined as unknown as number,
query: undefined as unknown as string,
type: undefined as unknown as string,
projectId: undefined as unknown as string,
onlySharedWithMe: undefined as unknown as boolean,
},
{} as never,
);
return result.structuredContent as {
data: unknown[];
count: number;
n8nConnect?: { credentialTypes: string[]; nodes: string[] };
};
}
test('includes n8nConnect block when gateway is available', async () => {
const structured = await callHandler({ available: true });
expect(structured.n8nConnect).toEqual({
credentialTypes: ['openAiApi'],
nodes: ['@n8n/n8n-nodes-langchain.openAi'],
});
});
test('omits n8nConnect block when unavailable', async () => {
const structured = await callHandler({ available: false });
expect(structured.n8nConnect).toBeUndefined();
});
});
});
});
@@ -0,0 +1,91 @@
import type { AiGatewayConfigDto } from '@n8n/api-types';
import { mockInstance } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import { Telemetry } from '@/telemetry';
import { createListN8nConnectServicesTool } from '../tools/list-n8n-connect-services.tool';
const user = Object.assign(new User(), { id: 'user-1' });
const fullConfig: AiGatewayConfigDto = {
nodes: ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.lmChatOpenAi'],
credentialTypes: ['openAiApi'],
providerConfig: {
openAiApi: { gatewayPath: '/v1/gateway/openai/v1', urlField: 'url', apiKeyField: 'apiKey' },
},
supportedActions: {
'@n8n/n8n-nodes-langchain.openAi': {
text: ['message', 'response'],
},
},
minNodeTypeVersion: { '@n8n/n8n-nodes-langchain.openAi': 1.2 },
hiddenNodeProperties: { '@n8n/n8n-nodes-langchain.openAi': ['baseURL'] },
} as AiGatewayConfigDto;
function makeMocks(opts: { available?: boolean; config?: AiGatewayConfigDto } = {}) {
const aiGatewayService = mock<AiGatewayService>();
if (opts.available === false) {
aiGatewayService.isAvailable.mockResolvedValue({ available: false });
} else {
aiGatewayService.isAvailable.mockResolvedValue({
available: true,
config: opts.config ?? fullConfig,
});
}
const telemetry = mockInstance(Telemetry, { track: vi.fn() });
return { aiGatewayService, telemetry };
}
describe('list_n8n_connect_services MCP tool', () => {
test('registers under the name list_n8n_connect_services', () => {
const { aiGatewayService, telemetry } = makeMocks();
const tool = createListN8nConnectServicesTool(user, aiGatewayService, telemetry);
expect(tool.name).toBe('list_n8n_connect_services');
expect(tool.config.annotations).toMatchObject({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
});
});
test('returns full coverage payload when available', async () => {
const { aiGatewayService, telemetry } = makeMocks({ available: true });
const tool = createListN8nConnectServicesTool(user, aiGatewayService, telemetry);
const result = await tool.handler({}, {} as never);
expect(result.structuredContent).toEqual({
available: true,
credentialTypes: ['openAiApi'],
nodes: ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.lmChatOpenAi'],
supportedActions: {
'@n8n/n8n-nodes-langchain.openAi': { text: ['message', 'response'] },
},
minNodeTypeVersion: { '@n8n/n8n-nodes-langchain.openAi': 1.2 },
hiddenNodeProperties: { '@n8n/n8n-nodes-langchain.openAi': ['baseURL'] },
});
});
test('returns { available: false } when unavailable', async () => {
const { aiGatewayService, telemetry } = makeMocks({ available: false });
const tool = createListN8nConnectServicesTool(user, aiGatewayService, telemetry);
const result = await tool.handler({}, {} as never);
expect(result.structuredContent).toEqual({ available: false });
});
test('emits USER_CALLED_MCP_TOOL_EVENT with tool_name and success', async () => {
const { aiGatewayService, telemetry } = makeMocks();
const tool = createListN8nConnectServicesTool(user, aiGatewayService, telemetry);
await tool.handler({}, {} as never);
expect(telemetry.track).toHaveBeenCalledWith(
'User called mcp tool',
expect.objectContaining({
user_id: 'user-1',
tool_name: 'list_n8n_connect_services',
results: { success: true, data: { available: true } },
}),
);
});
});
@@ -0,0 +1,30 @@
import { getMcpInstructions } from '../tools/workflow-builder/mcp-instructions';
describe('getMcpInstructions', () => {
test('returns intro-only string when builder is disabled', () => {
const instructions = getMcpInstructions(false);
expect(instructions).toContain('official MCP server for n8n');
expect(instructions).not.toContain('n8nConnect');
});
test('includes n8n credits hint when builder is enabled and n8n Connect is available', () => {
const instructions = getMcpInstructions(true, true);
expect(instructions).toContain('nodes covered by n8n credits');
expect(instructions).toContain('n8nConnect.nodes');
expect(instructions).toContain('n8n credits');
expect(instructions).toContain('list_n8n_connect_services');
});
test('omits n8n credits hint when n8n Connect is not available', () => {
const instructions = getMcpInstructions(true, false);
expect(instructions).toContain('official MCP server for n8n');
expect(instructions).not.toContain('n8n credits');
expect(instructions).not.toContain('n8nConnect');
expect(instructions).not.toContain('list_n8n_connect_services');
});
test('omits n8n credits hint by default', () => {
const instructions = getMcpInstructions(true);
expect(instructions).not.toContain('n8n credits');
});
});
@@ -30,6 +30,7 @@ import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.ser
import { NodeCatalogService } from '@/node-catalog';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@@ -115,6 +116,9 @@ describe('McpService scope enforcement', () => {
mockInstance(WorkflowsConfig),
mockInstance(WorkflowPublishedDataService),
mockInstance(SubworkflowPolicyChecker),
mockInstance(AiGatewayService, {
isAvailable: vi.fn().mockResolvedValue({ available: false }),
}),
);
beforeEach(() => {
@@ -38,6 +38,7 @@ import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.ser
import { NodeCatalogService } from '@/node-catalog';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@@ -54,6 +55,11 @@ import { WorkflowService } from '@/workflows/workflow.service';
import { McpService } from '../mcp.service';
const mockAiGatewayService = () =>
mockInstance(AiGatewayService, {
isAvailable: vi.fn().mockResolvedValue({ available: false }),
});
describe('McpService', () => {
let mcpService: McpService;
let activeExecutions: ActiveExecutions;
@@ -106,6 +112,7 @@ describe('McpService', () => {
mockInstance(WorkflowsConfig),
mockInstance(WorkflowPublishedDataService),
mockInstance(SubworkflowPolicyChecker),
mockAiGatewayService(),
);
});
@@ -154,6 +161,7 @@ describe('McpService', () => {
mockInstance(WorkflowsConfig),
mockInstance(WorkflowPublishedDataService),
mockInstance(SubworkflowPolicyChecker),
mockAiGatewayService(),
);
expect(queueMcpService.isQueueMode).toBe(true);
@@ -355,6 +363,7 @@ describe('McpService', () => {
mockInstance(WorkflowsConfig),
mockInstance(WorkflowPublishedDataService),
mockInstance(SubworkflowPolicyChecker),
mockAiGatewayService(),
);
const user = Object.assign(new User(), { id: 'user-1' });
@@ -464,6 +473,7 @@ describe('McpService', () => {
mockInstance(WorkflowsConfig),
mockInstance(WorkflowPublishedDataService),
mockInstance(SubworkflowPolicyChecker),
mockAiGatewayService(),
);
const server = await service.getServer(user, false);
@@ -514,6 +524,7 @@ describe('McpService', () => {
mockInstance(WorkflowsConfig),
mockInstance(WorkflowPublishedDataService),
mockInstance(SubworkflowPolicyChecker),
mockAiGatewayService(),
);
const server = await service.getServer(user, false);
@@ -588,6 +599,7 @@ describe('McpService', () => {
mockInstance(WorkflowsConfig),
mockInstance(WorkflowPublishedDataService),
mockInstance(SubworkflowPolicyChecker),
mockAiGatewayService(),
);
};
@@ -1,8 +1,10 @@
import type { AiGatewayConfigDto } from '@n8n/api-types';
import { User } from '@n8n/db';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { NodeCatalogService } from '@/node-catalog';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
@@ -29,18 +31,22 @@ describe('search-workflow-nodes MCP tool', () => {
const user = Object.assign(new User(), { id: 'user-1' });
let nodeCatalogService: Mocked<NodeCatalogService>;
let telemetry: Mocked<Telemetry>;
let aiGatewayService: Mocked<AiGatewayService>;
beforeEach(() => {
vi.clearAllMocks();
nodeCatalogService = mock<NodeCatalogService>();
telemetry = mock<Telemetry>();
aiGatewayService = mock<AiGatewayService>();
aiGatewayService.isAvailable.mockResolvedValue({ available: false });
nodeCatalogService.searchNodes.mockResolvedValue({
results: 'search-result',
queriesWithNoResults: [],
});
});
const createTool = () => createSearchWorkflowNodesTool(user, nodeCatalogService, telemetry);
const createTool = () =>
createSearchWorkflowNodesTool(user, nodeCatalogService, telemetry, aiGatewayService);
test('returns search results and tracks queries with no results', async () => {
nodeCatalogService.searchNodes.mockResolvedValueOnce({
@@ -111,4 +117,39 @@ describe('search-workflow-nodes MCP tool', () => {
}),
);
});
describe('n8nConnect block', () => {
test('includes n8nConnect block when gateway is available', async () => {
aiGatewayService.isAvailable.mockResolvedValue({
available: true,
config: {
nodes: ['@n8n/n8n-nodes-langchain.openAi'],
credentialTypes: ['openAiApi'],
providerConfig: {},
} as AiGatewayConfigDto,
});
const tool = createTool();
const result = await tool.handler({ queries: ['openai'] }, {} as never);
expect(result.structuredContent).toEqual({
results: 'search-result',
n8nConnect: {
credentialTypes: ['openAiApi'],
nodes: ['@n8n/n8n-nodes-langchain.openAi'],
},
});
// Also mirrored into the unstructured content for text-only clients.
expect((result.content[0] as { text: string }).text).toBe(
'search-result\n\nn8nConnect: {"credentialTypes":["openAiApi"],"nodes":["@n8n/n8n-nodes-langchain.openAi"]}',
);
});
test('omits n8nConnect block when unavailable', async () => {
const tool = createTool();
const result = await tool.handler({ queries: ['openai'] }, {} as never);
expect(result.structuredContent).toEqual({ results: 'search-result' });
expect((result.content[0] as { text: string }).text).toBe('search-result');
});
});
});
@@ -8,12 +8,14 @@ import {
type INode,
} from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { z } from 'zod';
import { CollaborationService } from '@/collaboration/collaboration.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker';
import { NodeTypes } from '@/node-types';
import { TagService } from '@/services/tag.service';
@@ -26,10 +28,12 @@ import { WorkflowService } from '@/workflows/workflow.service';
import { createUpdateWorkflowTool } from '../tools/workflow-builder/update-workflow.tool';
const mockAutoPopulateNodeCredentials = vi.fn();
const mockTrackAutoassignOutcomes = vi.fn();
vi.mock('../tools/workflow-builder/credentials-auto-assign', () => ({
autoPopulateNodeCredentials: (...args: unknown[]) =>
mockAutoPopulateNodeCredentials(...args) as unknown,
stripNullCredentialStubs: vi.fn(),
trackAutoassignOutcomes: (...args: unknown[]) => mockTrackAutoassignOutcomes(...args) as unknown,
}));
const mockValidateJSON = vi.fn().mockReturnValue([]);
@@ -145,7 +149,11 @@ describe('update-workflow MCP tool', () => {
ensureWorkflowEditable: vi.fn().mockResolvedValue(undefined),
broadcastWorkflowUpdate: vi.fn().mockResolvedValue(undefined),
});
mockAutoPopulateNodeCredentials.mockResolvedValue({ assignments: [], skippedHttpNodes: [] });
mockAutoPopulateNodeCredentials.mockResolvedValue({
assignments: [],
skippedHttpNodes: [],
outcomes: [],
});
mockValidateJSON.mockReturnValue([]);
dataTableOps = {
@@ -174,6 +182,9 @@ describe('update-workflow MCP tool', () => {
});
});
const aiGatewayService = mock<AiGatewayService>();
aiGatewayService.isAvailable.mockResolvedValue({ available: false });
const createTool = () =>
createUpdateWorkflowTool(
user,
@@ -190,6 +201,7 @@ describe('update-workflow MCP tool', () => {
globalConfig,
subworkflowPolicyChecker,
workflowPublishedDataService,
aiGatewayService,
);
const callHandler = async (
@@ -842,6 +854,7 @@ describe('update-workflow MCP tool', () => {
globalConfig,
subworkflowPolicyChecker,
workflowPublishedDataService,
aiGatewayService,
);
findWorkflowMock.mockImplementation(async (id: string) =>
id === 'wf-1'
@@ -974,10 +987,25 @@ describe('update-workflow MCP tool', () => {
test('reports auto-assigned credentials in the response', async () => {
mockAutoPopulateNodeCredentials.mockResolvedValue({
assignments: [{ nodeName: 'C', credentialName: 'My Slack', credentialType: 'slackApi' }],
assignments: [
{
nodeName: 'C',
credentialName: 'My Slack',
credentialType: 'slackApi',
source: 'user',
},
{
nodeName: 'D',
credentialName: 'n8n credits',
credentialType: 'openAiApi',
source: 'aiGateway',
},
],
skippedHttpNodes: [],
outcomes: [],
});
const tool = createTool();
const result = await callHandler({
workflowId: 'wf-1',
operations: [
@@ -990,14 +1018,92 @@ describe('update-workflow MCP tool', () => {
const response = parseResult(result);
expect(response.autoAssignedCredentials).toEqual([
{ nodeName: 'C', credentialName: 'My Slack', credentialType: 'slackApi' },
{ nodeName: 'C', credentialName: 'My Slack', credentialType: 'slackApi', source: 'user' },
{
nodeName: 'D',
credentialName: 'n8n credits',
credentialType: 'openAiApi',
source: 'aiGateway',
},
]);
// The `source` field must be declared in the item schema; validate items
// strictly so a returned key missing from the schema fails the test
// (MCP publishes the schema with additionalProperties: false).
const itemsField = (
tool.config.outputSchema as {
autoAssignedCredentials: z.ZodOptional<z.ZodArray<z.ZodObject<z.ZodRawShape>>>;
}
).autoAssignedCredentials.unwrap();
expect(() =>
z.array(itemsField.element.strict()).parse(response.autoAssignedCredentials),
).not.toThrow();
});
test('tracks auto-assign outcomes with the persisted workflow id after update', async () => {
mockAutoPopulateNodeCredentials.mockResolvedValue({
assignments: [],
skippedHttpNodes: [],
outcomes: [
{
nodeName: 'C',
credentialType: 'openAiApi',
source: 'aiGateway',
hadUserCredential: false,
aiGatewayAvailable: true,
},
],
});
await callHandler({
workflowId: 'wf-1',
operations: [
{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.slack', typeVersion: 1 } },
],
});
expect(mockTrackAutoassignOutcomes).toHaveBeenCalledTimes(1);
const trackArgs = mockTrackAutoassignOutcomes.mock.calls[0];
expect(trackArgs[2]).toBe('update_workflow');
expect(trackArgs[5]).toBe('wf-1');
// Tracking runs only after the update persists.
expect(updateMock.mock.invocationCallOrder[0]).toBeLessThan(
mockTrackAutoassignOutcomes.mock.invocationCallOrder[0],
);
});
test('does not track auto-assign outcomes when the update fails to persist', async () => {
mockAutoPopulateNodeCredentials.mockResolvedValue({
assignments: [],
skippedHttpNodes: [],
outcomes: [
{
nodeName: 'C',
credentialType: 'openAiApi',
source: 'aiGateway',
hadUserCredential: false,
aiGatewayAvailable: true,
},
],
});
updateMock.mockRejectedValueOnce(new Error('update failed'));
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.slack', typeVersion: 1 } },
],
});
expect(result.isError).toBe(true);
expect(mockTrackAutoassignOutcomes).not.toHaveBeenCalled();
});
test('reports skipped HTTP nodes in the note', async () => {
mockAutoPopulateNodeCredentials.mockResolvedValue({
assignments: [],
skippedHttpNodes: ['HTTP Request'],
outcomes: [],
});
const result = await callHandler({
@@ -2020,6 +2126,7 @@ describe('update-workflow MCP tool', () => {
globalConfig,
subworkflowPolicyChecker,
workflowPublishedDataService,
aiGatewayService,
);
await callHandler(
@@ -2056,6 +2163,7 @@ describe('update-workflow MCP tool', () => {
globalConfig,
subworkflowPolicyChecker,
workflowPublishedDataService,
aiGatewayService,
);
const result = await callHandler(
@@ -0,0 +1,18 @@
import type { AiGatewayAvailability } from '@/services/ai-gateway.service';
import type { N8nConnectCoverage } from './mcp.types';
/**
* Maps a gateway availability result to the `{ credentialTypes, nodes }` coverage
* snapshot surfaced in tool output, or `undefined` when the gateway is unavailable.
* Single source of truth for the coverage shape across the MCP discovery tools.
*/
export function toN8nConnectCoverage(
availability: AiGatewayAvailability,
): N8nConnectCoverage | undefined {
if (!availability.available) return undefined;
return {
credentialTypes: availability.config.credentialTypes,
nodes: availability.config.nodes,
};
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const TOOLS_BY_SCOPE: Record<McpScope, readonly string[]> = {
'execution:read': ['get_execution', 'search_executions'],
// explore_node_resources queries external services with stored credentials,
// so it must sit behind the credential scope rather than a workflow one.
'credential:read': ['list_credentials', 'explore_node_resources'],
'credential:read': ['list_credentials', 'list_n8n_connect_services', 'explore_node_resources'],
'dataTable:read': ['search_data_tables'],
// Writing requires finding tables, so search rides along.
'dataTable:write': [
@@ -13,6 +13,7 @@ import {
export const USER_CONNECTED_TO_MCP_EVENT = 'User connected to MCP server';
export const USER_CALLED_MCP_TOOL_EVENT = 'User called mcp tool';
export const MCP_PREVIEW_RENDER_REQUESTED_EVENT = 'MCP App preview render requested';
export const MCP_CREDENTIALS_AUTOASSIGN_EVENT = 'MCP credentials autoassign';
/**
* Message constants
@@ -21,6 +22,11 @@ export const UNAUTHORIZED_ERROR_MESSAGE = 'Unauthorized';
export const INTERNAL_SERVER_ERROR_MESSAGE = 'Internal server error';
export const MCP_ACCESS_DISABLED_ERROR_MESSAGE = 'MCP access is disabled';
/**
* Tool name constants
*/
export const LIST_N8N_CONNECT_SERVICES_TOOL_NAME = 'list_n8n_connect_services';
/**
* Triggers supported in production mode for MCP execution
*/
+19 -1
View File
@@ -36,6 +36,7 @@ import { createSearchExecutionsTool } from './tools/search-executions.tool';
import { createWorkflowDetailsTool } from './tools/get-workflow-details.tool';
import { createGetWorkflowHistoryTool } from './tools/get-workflow-history.tool';
import { createGetWorkflowVersionTool } from './tools/get-workflow-version.tool';
import { createListN8nConnectServicesTool } from './tools/list-n8n-connect-services.tool';
import { createListCredentialsTool } from './tools/list-credentials.tool';
import { createListTagsTool } from './tools/list-tags.tool';
import { createPublishWorkflowTool } from './tools/publish-workflow.tool';
@@ -60,6 +61,7 @@ import { createValidateWorkflowCodeTool } from './tools/workflow-builder/validat
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@@ -147,6 +149,7 @@ export class McpService {
private readonly workflowsConfig: WorkflowsConfig,
private readonly workflowPublishedDataService: WorkflowPublishedDataService,
private readonly subworkflowPolicyChecker: SubworkflowPolicyChecker,
private readonly aiGatewayService: AiGatewayService,
) {}
async resolveMcpAppsVariant(user: User): Promise<McpAppsResolution> {
@@ -225,6 +228,9 @@ export class McpService {
>(async () => await import('@modelcontextprotocol/sdk/server/mcp.js'));
const builderEnabled = this.globalConfig.endpoints.mcpBuilderEnabled;
const n8nConnectAvailable = builderEnabled
? (await this.aiGatewayService.isAvailable()).available
: false;
const allowedToolNames = getAllowedToolNames(grantedScopes);
// The builder walkthrough is only useful when the grant can actually
// create workflows; a read-only grant gets the plain intro instead of
@@ -238,7 +244,7 @@ export class McpService {
version: builderEnabled ? '1.1.0' : '1.0.0',
},
{
instructions: getMcpInstructions(builderInstructionsEnabled),
instructions: getMcpInstructions(builderInstructionsEnabled, n8nConnectAvailable),
},
);
@@ -357,8 +363,16 @@ export class McpService {
user,
this.credentialsService,
this.telemetry,
this.aiGatewayService,
);
const listN8nConnectServicesTool = createListN8nConnectServicesTool(
user,
this.aiGatewayService,
this.telemetry,
);
registerIfAllowed(listCredentialsTool);
registerIfAllowed(listN8nConnectServicesTool);
if (!this.globalConfig.tags.disabled) {
const listTagsTool = createListTagsTool(user, this.tagService, this.telemetry);
@@ -428,6 +442,7 @@ export class McpService {
user,
this.nodeCatalogService,
this.telemetry,
this.aiGatewayService,
);
registerIfAllowed(searchNodesTool);
@@ -435,6 +450,7 @@ export class McpService {
user,
this.nodeCatalogService,
this.telemetry,
this.aiGatewayService,
);
registerIfAllowed(getNodeTypesTool);
@@ -464,6 +480,7 @@ export class McpService {
this.credentialsService,
this.projectRepository,
dataTableOps,
this.aiGatewayService,
);
// The preview app only accompanies the create tool, so both are gated
@@ -539,6 +556,7 @@ export class McpService {
this.globalConfig,
this.subworkflowPolicyChecker,
this.workflowPublishedDataService,
this.aiGatewayService,
);
registerIfAllowed(updateTool);
+10
View File
@@ -129,6 +129,16 @@ export type UserCalledMCPToolEventPayload = {
};
};
/**
* n8n Connect coverage snapshot surfaced in tool output when the
* gateway is available: the credential and node types it can provide managed
* credentials for.
*/
export type N8nConnectCoverage = {
credentialTypes: string[];
nodes: string[];
};
export type MCPTriggersMap = {
[K in keyof typeof SUPPORTED_PRODUCTION_MCP_TRIGGERS]: INode[];
};
@@ -3,10 +3,16 @@ import z from 'zod';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { ListQuery } from '@/requests';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types';
import { toN8nConnectCoverage } from '../mcp-ai-gateway.helper';
import { LIST_N8N_CONNECT_SERVICES_TOOL_NAME, USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type {
N8nConnectCoverage,
ToolDefinition,
UserCalledMCPToolEventPayload,
} from '../mcp.types';
import { createLimitSchema } from './schemas';
const MAX_RESULTS = 200;
@@ -41,6 +47,20 @@ const homeProjectSchema = z
.nullable()
.describe('The project that owns the credential, if available');
const n8nConnectSchema = z
.object({
credentialTypes: z
.array(z.string())
.describe('Credential type names that n8n Connect can provide (e.g. "openAiApi").'),
nodes: z
.array(z.string())
.describe('Node types covered by n8n Connect (e.g. "@n8n/n8n-nodes-langchain.openAi").'),
})
.optional()
.describe(
`Present when n8n Connect is available for this instance. Omitted otherwise. Candidate coverage only — actual eligibility for a managed credential also depends on the node action, minimum type version, and hidden properties; call ${LIST_N8N_CONNECT_SERVICES_TOOL_NAME} for the authoritative contract.`,
);
const outputSchema = {
data: z
.array(
@@ -60,6 +80,7 @@ const outputSchema = {
)
.describe('List of credentials accessible to the current user'),
count: z.number().int().min(0).describe('Number of credentials returned'),
n8nConnect: n8nConnectSchema,
error: z.string().optional().describe('Error message when the tool failed'),
} satisfies z.ZodRawShape;
@@ -84,6 +105,7 @@ export type ListCredentialsItem = {
export type ListCredentialsResult = {
data: ListCredentialsItem[];
count: number;
n8nConnect?: N8nConnectCoverage;
error?: string;
};
@@ -91,6 +113,7 @@ export const createListCredentialsTool = (
user: User,
credentialsService: CredentialsService,
telemetry: Telemetry,
aiGatewayService: AiGatewayService,
): ToolDefinition<typeof inputSchema> => ({
name: 'list_credentials',
config: {
@@ -128,6 +151,9 @@ export const createListCredentialsTool = (
onlySharedWithMe,
});
const coverage = toN8nConnectCoverage(await aiGatewayService.isAvailable());
if (coverage) payload.n8nConnect = coverage;
telemetryPayload.results = {
success: true,
data: { count: payload.count },
@@ -0,0 +1,105 @@
import type { User } from '@n8n/db';
import z from 'zod';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { Telemetry } from '@/telemetry';
import { LIST_N8N_CONNECT_SERVICES_TOOL_NAME, USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types';
const inputSchema = {} satisfies z.ZodRawShape;
const outputSchema = {
available: z
.boolean()
.describe(
'True when n8n Connect is available for this instance. When false, the remaining fields are omitted.',
),
credentialTypes: z
.array(z.string())
.optional()
.describe('Credential type names Connect can provide (e.g. "openAiApi").'),
nodes: z
.array(z.string())
.optional()
.describe('Node types Connect covers (e.g. "@n8n/n8n-nodes-langchain.openAi").'),
supportedActions: z
.record(z.record(z.array(z.string())))
.optional()
.describe(
'Per-node allowlist keyed by node type, then resource (or `__operation_only__` for nodes without a resource dimension). Values are supported operation names.',
),
minNodeTypeVersion: z
.record(z.number())
.optional()
.describe('Minimum `typeVersion` per node type for Connect coverage.'),
hiddenNodeProperties: z
.record(z.array(z.string()))
.optional()
.describe('Per-node property names hidden from the user when Connect provides the credential.'),
} satisfies z.ZodRawShape;
/**
* Returns the current n8n Connect coverage snapshot for the
* instance: which node types and credential types Connect can serve, plus
* per-node action allowlists, min versions, and hidden properties.
*
* Read-only. Omits all coverage fields when Connect is unavailable
* (unlicensed, misconfigured, or gateway down) — callers should key on
* `available: false` and fall back to user credentials.
*/
export const createListN8nConnectServicesTool = (
user: User,
aiGatewayService: AiGatewayService,
telemetry: Telemetry,
): ToolDefinition<typeof inputSchema> => ({
name: LIST_N8N_CONNECT_SERVICES_TOOL_NAME,
config: {
description:
'List n8n Connect coverage: node and credential types the platform can provide managed credentials for, plus supported resource+operation combinations, minimum type versions, and hidden node properties. Use this to decide which nodes let the user skip credential setup.',
inputSchema,
outputSchema,
annotations: {
title: 'List n8n Connect services',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
handler: async () => {
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: LIST_N8N_CONNECT_SERVICES_TOOL_NAME,
};
const availability = await aiGatewayService.isAvailable();
if (!availability.available) {
const payload = { available: false as const };
telemetryPayload.results = { success: true, data: { available: false } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
return {
content: [{ type: 'text', text: JSON.stringify(payload) }],
structuredContent: payload,
};
}
const { config } = availability;
const payload = {
available: true as const,
credentialTypes: config.credentialTypes,
nodes: config.nodes,
...(config.supportedActions ? { supportedActions: config.supportedActions } : {}),
...(config.minNodeTypeVersion ? { minNodeTypeVersion: config.minNodeTypeVersion } : {}),
...(config.hiddenNodeProperties ? { hiddenNodeProperties: config.hiddenNodeProperties } : {}),
};
telemetryPayload.results = { success: true, data: { available: true } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
return {
content: [{ type: 'text', text: JSON.stringify(payload) }],
structuredContent: payload,
};
},
});
@@ -0,0 +1,112 @@
import type { AiGatewayConfigDto } from '@n8n/api-types';
import type { INode } from 'n8n-workflow';
import { stripToolSuffix } from '@/utils';
/** Sentinel key the gateway uses for nodes with a flat `operation` param (no resource). */
const OPERATION_ONLY = '__operation_only__';
export type AiGatewayEligibilityReason =
| 'nodeNotCovered'
| 'credentialTypeNotCovered'
| 'versionTooLow'
| 'unsupportedAction'
| 'hiddenPropertySet';
export type AiGatewayEligibility =
| { eligible: true }
| { eligible: false; reason: AiGatewayEligibilityReason; details?: string };
/**
* Decides whether the AI Gateway can serve credentials for `node` of type
* `credentialType`, given the current gateway config. Pure function — no I/O.
* Callers use this from within auto-assign: on `eligible: true` attach the
* gateway sentinel; on `eligible: false` leave the slot empty (pre-change
* behavior) and log the `reason` for telemetry.
*
* `resolvedParameters` (parameters with node-type defaults applied) is used ONLY
* for the `resource`/`operation` action check, so a node relying on its default
* action stays eligible. The `hiddenNodeProperties` check deliberately uses the
* raw `node.parameters` — a hidden property that only carries its default value
* was not set by the user and must not disqualify the node.
*/
export function checkAiGatewayEligibility(
node: Pick<INode, 'type' | 'typeVersion' | 'parameters'>,
credentialType: string,
config: AiGatewayConfigDto,
resolvedParameters?: Record<string, unknown>,
): AiGatewayEligibility {
const key = resolveNodeKey(node.type, config.nodes);
if (!key) return { eligible: false, reason: 'nodeNotCovered' };
if (!config.credentialTypes.includes(credentialType)) {
return { eligible: false, reason: 'credentialTypeNotCovered' };
}
const minVersion = config.minNodeTypeVersion?.[key];
if (minVersion !== undefined && node.typeVersion < minVersion) {
return {
eligible: false,
reason: 'versionTooLow',
details: `requires typeVersion >= ${minVersion}`,
};
}
const hidden = config.hiddenNodeProperties?.[key];
if (hidden?.length) {
const params = node.parameters ?? {};
const offending = hidden.find((prop) => hasNestedProperty(params, prop));
if (offending !== undefined) {
return {
eligible: false,
reason: 'hiddenPropertySet',
details: `property "${offending}" is hidden when using AI Gateway`,
};
}
}
const actions = config.supportedActions?.[key];
if (actions) {
// Read resource/operation from defaults-resolved params so a node relying
// on its default action is judged against the values it will actually run.
const params = resolvedParameters ?? node.parameters ?? {};
const resource = typeof params.resource === 'string' ? params.resource : OPERATION_ONLY;
const operation = typeof params.operation === 'string' ? params.operation : undefined;
const allowedOps = actions[resource];
// When the gateway defines an action allowlist for this node, require an
// explicit operation that is on the list. A missing operation is not
// eligible — we don't grant a managed credential for an unspecified action.
if (!allowedOps || operation === undefined || !allowedOps.includes(operation)) {
return {
eligible: false,
reason: 'unsupportedAction',
details:
resource === OPERATION_ONLY
? `operation "${operation ?? ''}" not supported`
: `${resource}.${operation ?? ''} not supported`,
};
}
}
return { eligible: true };
}
function resolveNodeKey(nodeType: string, nodes: string[]): string | null {
if (nodes.includes(nodeType)) return nodeType;
const stripped = stripToolSuffix(nodeType);
if (stripped !== nodeType && nodes.includes(stripped)) return stripped;
return null;
}
/** Whether `key` appears as a property name at any depth in `value` (nested objects and arrays included). */
function hasNestedProperty(value: unknown, key: string): boolean {
if (Array.isArray(value)) {
return value.some((item) => hasNestedProperty(item, key));
}
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(record, key)) return true;
return Object.values(record).some((v) => hasNestedProperty(v, key));
}
return false;
}
@@ -4,7 +4,11 @@ import z from 'zod';
import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check';
import { MCP_CREATE_WORKFLOW_FROM_CODE_TOOL, CODE_BUILDER_VALIDATE_TOOL } from './constants';
import { validateWorkflowCredentialReferences } from './credential-validation';
import { autoPopulateNodeCredentials, stripNullCredentialStubs } from './credentials-auto-assign';
import {
autoPopulateNodeCredentials,
stripNullCredentialStubs,
trackAutoassignOutcomes,
} from './credentials-auto-assign';
import { validateDataTableReferencesForWorkflow } from './data-table-validation';
import { sanitizeSkillsUsed, SKILLS_USED_PARAM_DESCRIPTION } from './skills-used';
import {
@@ -21,6 +25,7 @@ import type { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { DataTableUserOperations } from '@/modules/data-table/data-table-proxy.service';
import type { NodeTypes } from '@/node-types';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { UrlService } from '@/services/url.service';
import type { Telemetry } from '@/telemetry';
import { resolveNodeWebhookIds } from '@/workflow-helpers';
@@ -95,6 +100,12 @@ const outputSchema = {
nodeName: z.string().describe('The name of the node that had credentials auto-assigned'),
credentialName: z.string().describe('The name of the credential that was auto-assigned'),
credentialType: z.string().describe('The credential type that was auto-assigned'),
source: z
.enum(['user', 'aiGateway'])
.optional()
.describe(
'Where the credential came from: "user" for an existing user credential, "aiGateway" for a managed n8n Connect credential.',
),
}),
)
.optional()
@@ -157,6 +168,7 @@ export const createCreateWorkflowFromCodeTool = (
credentialsService: CredentialsService,
projectRepository: ProjectRepository,
dataTableOps: DataTableUserOperations,
aiGatewayService: AiGatewayService,
): ToolDefinition<typeof inputSchema> => ({
name: MCP_CREATE_WORKFLOW_FROM_CODE_TOOL.toolName,
config: {
@@ -278,14 +290,18 @@ export const createCreateWorkflowFromCodeTool = (
throw new Error(dataTableCheck.error);
}
const { assignments: credentialAssignments, skippedHttpNodes } =
await autoPopulateNodeCredentials(
newWorkflow,
user,
nodeTypes,
credentialsService,
effectiveProjectId,
);
const {
assignments: credentialAssignments,
skippedHttpNodes,
outcomes: autoAssignOutcomes,
} = await autoPopulateNodeCredentials(
newWorkflow,
user,
nodeTypes,
credentialsService,
effectiveProjectId,
aiGatewayService,
);
// Explicit credential ids in the generated code bypass auto-assignment,
// so verify they're reachable from the target project. This matches the
@@ -315,6 +331,16 @@ export const createCreateWorkflowFromCodeTool = (
versionDescription: versionMetadata.description,
});
const nodeTypesByName = new Map(savedWorkflow.nodes.map((n) => [n.name, n.type]));
trackAutoassignOutcomes(
telemetry,
user.id,
'create_workflow_from_code',
autoAssignOutcomes,
nodeTypesByName,
savedWorkflow.id,
);
const baseUrl = urlService.getInstanceBaseUrl();
const workflowUrl = `${baseUrl}/workflow/${savedWorkflow.id}`;
@@ -1,21 +1,63 @@
import type { AiGatewayConfigDto } from '@n8n/api-types';
import type { User } from '@n8n/db';
import type { INode, INodeTypeDescription, IWorkflowBase } from 'n8n-workflow';
import type { INode, INodeParameters, INodeTypeDescription, IWorkflowBase } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { NodeTypes } from '@/node-types';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { Telemetry } from '@/telemetry';
import {
checkAiGatewayEligibility,
type AiGatewayEligibilityReason,
} from './ai-gateway-eligibility';
import { MCP_CREDENTIALS_AUTOASSIGN_EVENT } from '../../mcp.constants';
/** Display name written into AI Gateway-managed credential sentinels. User-facing brand. */
const AI_GATEWAY_CREDENTIAL_NAME = 'n8n credits';
export interface CredentialAssignment {
nodeName: string;
credentialName: string;
credentialType: string;
source?: 'user' | 'aiGateway';
}
export type SlotSource = 'user' | 'aiGateway' | 'none';
export type ReasonNotAiGateway = AiGatewayEligibilityReason | 'notAvailable';
export interface SlotOutcome {
nodeName: string;
credentialType: string;
source: SlotSource;
hadUserCredential: boolean;
aiGatewayAvailable: boolean;
reasonNotAiGateway?: ReasonNotAiGateway;
}
export interface AutoAssignResult {
assignments: CredentialAssignment[];
skippedHttpNodes: string[];
outcomes: SlotOutcome[];
}
/**
* Telemetry payload for the `MCP credentials autoassign` event. Reuses `SlotSource`
* and `ReasonNotAiGateway` so the tracked values stay aligned with the slot outcomes.
*/
export type McpCredentialsAutoassignEventPayload = {
user_id: string;
tool_name: 'create_workflow_from_code' | 'update_workflow';
node_type: string;
credential_type: string;
source: SlotSource;
had_user_credential: boolean;
ai_gateway_available: boolean;
reason_not_ai_gateway?: ReasonNotAiGateway;
};
const HTTP_NODE_TYPES = new Set([
'n8n-nodes-base.httpRequest',
'@n8n/n8n-nodes-langchain.toolHttpRequest',
@@ -23,12 +65,83 @@ const HTTP_NODE_TYPES = new Set([
]);
/**
* Auto-populates missing credentials on workflow nodes by assigning
* a credential of the matching type that the user can actually use
* in the target project. Only credentials accessible to both the user
* and the project are considered, preventing cross-project assignments.
* Normalizes n8n Connect (`__aiGatewayManaged`) markers on the given nodes.
*
* HTTP Request nodes are skipped for security
* The marker is server-assigned, so it must correspond to an eligible slot. For every
* marked credential — across all nodes and keys, including ones `autoPopulateNodeCredentials`
* skips (HTTP/disabled nodes, undeclared keys) — the marker is kept (canonicalized to the
* sentinel) when it still passes `checkAiGatewayEligibility`, and removed otherwise.
*/
export function reconcileAiGatewayMarkers(
nodes: INode[],
nodeTypes: NodeTypes,
aiGatewayConfig: AiGatewayConfigDto | undefined,
): void {
for (const node of nodes) {
if (!node.credentials) continue;
const credentials = node.credentials;
const markerTypes = Object.keys(credentials).filter(
(credentialType) => credentials[credentialType]?.__aiGatewayManaged,
);
if (markerTypes.length === 0) continue;
const nodeParameters = aiGatewayConfig ? resolveNodeParameters(node, nodeTypes) : undefined;
if (!aiGatewayConfig || !nodeParameters) {
for (const credentialType of markerTypes) delete credentials[credentialType];
continue;
}
for (const credentialType of markerTypes) {
if (
checkAiGatewayEligibility(node, credentialType, aiGatewayConfig, nodeParameters).eligible
) {
credentials[credentialType] = {
id: null,
name: AI_GATEWAY_CREDENTIAL_NAME,
__aiGatewayManaged: true,
};
} else {
delete credentials[credentialType];
}
}
}
}
/** Resolves a node's parameters with defaults applied, or `undefined` if its type can't be resolved. */
function resolveNodeParameters(node: INode, nodeTypes: NodeTypes): INodeParameters | undefined {
let description: INodeTypeDescription;
try {
description = nodeTypes.getByNameAndVersion(node.type, node.typeVersion).description;
} catch {
return undefined;
}
return (
NodeHelpers.getNodeParameters(
description.properties,
node.parameters,
true,
false,
node,
description,
) ?? node.parameters
);
}
/**
* Auto-populates missing credentials on workflow nodes.
*
* Resolution order per slot:
* 1. Explicit credential id in the node — untouched
* 2. First usable user credential of the matching type
* 3. AI Gateway ("n8n Connect") sentinel when eligible and `aiGatewayService` is passed
* 4. Leave empty
*
* HTTP Request nodes are skipped for security.
*
* When `aiGatewayService` is omitted, behavior is byte-for-byte pre-change:
* only steps 1, 2, 4 run, and `outcomes` records
* `reasonNotAiGateway: 'notAvailable'` for any unfilled slot.
*/
export async function autoPopulateNodeCredentials(
workflow: IWorkflowBase,
@@ -36,6 +149,7 @@ export async function autoPopulateNodeCredentials(
nodeTypes: NodeTypes,
credentialsService: CredentialsService,
projectId: string,
aiGatewayService?: AiGatewayService,
): Promise<AutoAssignResult> {
const usableCredentials = await credentialsService.getCredentialsAUserCanUseInAWorkflow(user, {
projectId,
@@ -48,8 +162,18 @@ export async function autoPopulateNodeCredentials(
credentialsByType.set(cred.type, list);
}
const availability = aiGatewayService
? await aiGatewayService.isAvailable()
: ({ available: false } as const);
const aiGatewayConfig: AiGatewayConfigDto | undefined = availability.available
? availability.config
: undefined;
reconcileAiGatewayMarkers(workflow.nodes, nodeTypes, aiGatewayConfig);
const assignments: CredentialAssignment[] = [];
const skippedHttpNodes: string[] = [];
const outcomes: SlotOutcome[] = [];
for (const node of workflow.nodes) {
if (node.disabled) continue;
@@ -94,24 +218,132 @@ export async function autoPopulateNodeCredentials(
const existing = node.credentials?.[credDesc.name];
if (existing?.id) continue;
const candidates = credentialsByType.get(credDesc.name);
if (!candidates?.length) continue;
// Assign the first available credential
node.credentials = node.credentials ?? {};
node.credentials[credDesc.name] = {
id: candidates[0].id,
name: candidates[0].name,
};
// Markers were validated by reconcileAiGatewayMarkers (eligible kept, rest
// stripped), so an eligible n8n Connect request is honored ahead of the user's
// own credentials.
if (existing?.__aiGatewayManaged) continue;
assignments.push({
const userCandidates = credentialsByType.get(credDesc.name);
const hadUserCredential = !!userCandidates?.length;
if (hadUserCredential) {
node.credentials = node.credentials ?? {};
node.credentials[credDesc.name] = {
id: userCandidates[0].id,
name: userCandidates[0].name,
};
assignments.push({
nodeName: node.name,
credentialName: userCandidates[0].name,
credentialType: credDesc.name,
source: 'user',
});
outcomes.push({
nodeName: node.name,
credentialType: credDesc.name,
source: 'user',
hadUserCredential: true,
aiGatewayAvailable: !!aiGatewayConfig,
});
continue;
}
if (aiGatewayConfig) {
const eligibility = checkAiGatewayEligibility(
node,
credDesc.name,
aiGatewayConfig,
nodeParametersWithDefaults,
);
if (eligibility.eligible) {
node.credentials = node.credentials ?? {};
node.credentials[credDesc.name] = {
id: null,
name: AI_GATEWAY_CREDENTIAL_NAME,
__aiGatewayManaged: true,
};
assignments.push({
nodeName: node.name,
credentialName: AI_GATEWAY_CREDENTIAL_NAME,
credentialType: credDesc.name,
source: 'aiGateway',
});
outcomes.push({
nodeName: node.name,
credentialType: credDesc.name,
source: 'aiGateway',
hadUserCredential: false,
aiGatewayAvailable: true,
});
continue;
}
outcomes.push({
nodeName: node.name,
credentialType: credDesc.name,
source: 'none',
hadUserCredential: false,
aiGatewayAvailable: true,
reasonNotAiGateway: eligibility.reason,
});
continue;
}
outcomes.push({
nodeName: node.name,
credentialName: candidates[0].name,
credentialType: credDesc.name,
source: 'none',
hadUserCredential: false,
aiGatewayAvailable: false,
reasonNotAiGateway: 'notAvailable',
});
}
}
return { assignments, skippedHttpNodes };
return { assignments, skippedHttpNodes, outcomes };
}
/**
* Emits telemetry for each slot outcome:
*
* - `MCP credentials autoassign` — MCP-specific detail (tool, reason a slot did not
* use n8n Connect, gateway availability) for every outcome.
* - `Node credential assigned` — the cross-surface attribution funnel shared with
* the canvas and Instance AI, for every slot that actually received a credential.
* The actor is `mcp`; `credential_kind` maps the slot's origin (`aiGateway` → n8n
* Connect, `user` → BYOK).
*/
export function trackAutoassignOutcomes(
telemetry: Telemetry,
userId: string,
toolName: 'create_workflow_from_code' | 'update_workflow',
outcomes: SlotOutcome[],
nodesByName?: Map<string, string>,
workflowId?: string,
): void {
for (const outcome of outcomes) {
const nodeType = nodesByName?.get(outcome.nodeName) ?? outcome.nodeName;
const payload: McpCredentialsAutoassignEventPayload = {
user_id: userId,
tool_name: toolName,
node_type: nodeType,
credential_type: outcome.credentialType,
source: outcome.source,
had_user_credential: outcome.hadUserCredential,
ai_gateway_available: outcome.aiGatewayAvailable,
...(outcome.reasonNotAiGateway ? { reason_not_ai_gateway: outcome.reasonNotAiGateway } : {}),
};
telemetry.track(MCP_CREDENTIALS_AUTOASSIGN_EVENT, payload);
if (outcome.source !== 'none') {
telemetry.track('Node credential assigned', {
credential_type: outcome.credentialType,
node_type: nodeType,
workflow_id: workflowId ?? '',
credential_kind: outcome.source === 'aiGateway' ? 'n8n_connect' : 'own',
source: 'mcp',
});
}
}
}
/**
@@ -2,11 +2,20 @@ import type { User } from '@n8n/db';
import z from 'zod';
import type { NodeCatalogService } from '@/node-catalog';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { Telemetry } from '@/telemetry';
import { CODE_BUILDER_GET_NODE_TYPES_TOOL } from './constants';
import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types';
import { toN8nConnectCoverage } from '../../mcp-ai-gateway.helper';
import {
LIST_N8N_CONNECT_SERVICES_TOOL_NAME,
USER_CALLED_MCP_TOOL_EVENT,
} from '../../mcp.constants';
import type {
N8nConnectCoverage,
ToolDefinition,
UserCalledMCPToolEventPayload,
} from '../../mcp.types';
const nodeRequestSchema = z.object({
nodeId: z.string().describe('The node type ID (e.g. "n8n-nodes-base.gmail")'),
@@ -27,6 +36,19 @@ const inputSchema = {
const outputSchema = {
definitions: z.string().describe('TypeScript type definitions for the requested nodes'),
n8nConnect: z
.object({
credentialTypes: z.array(z.string()).describe('Credential types n8n Connect can provide.'),
nodes: z
.array(z.string())
.describe(
'Node types n8n Connect may cover. Prefer these when the user has not specified an integration. Candidate coverage only — exact eligibility also depends on the node action, minimum type version, and hidden properties.',
),
})
.optional()
.describe(
`Present when n8n Connect is available. Candidate coverage — cross-reference against the returned node types, but call ${LIST_N8N_CONNECT_SERVICES_TOOL_NAME} for exact eligibility (supported actions, min versions, hidden properties).`,
),
} satisfies z.ZodRawShape;
type NodeRequest = z.infer<typeof nodeRequestSchema>;
@@ -39,6 +61,7 @@ export const createGetWorkflowNodeTypesTool = (
user: User,
nodeCatalogService: NodeCatalogService,
telemetry: Telemetry,
aiGatewayService: AiGatewayService,
): ToolDefinition<typeof inputSchema> => ({
name: CODE_BUILDER_GET_NODE_TYPES_TOOL.toolName,
config: {
@@ -62,14 +85,26 @@ export const createGetWorkflowNodeTypesTool = (
};
try {
const result = await nodeCatalogService.getNodeTypes(nodeIds);
const [result, availability] = await Promise.all([
nodeCatalogService.getNodeTypes(nodeIds),
aiGatewayService.isAvailable(),
]);
telemetryPayload.results = { success: true, data: { nodeIdCount: nodeIds.length } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
const structured: {
definitions: string;
n8nConnect?: N8nConnectCoverage;
} = { definitions: result };
const coverage = toN8nConnectCoverage(availability);
if (coverage) structured.n8nConnect = coverage;
const text = coverage ? `${result}\n\nn8nConnect: ${JSON.stringify(coverage)}` : result;
return {
content: [{ type: 'text', text: result }],
structuredContent: { definitions: result },
content: [{ type: 'text', text }],
structuredContent: structured,
};
} catch (error) {
telemetryPayload.results = {
@@ -18,10 +18,20 @@ import {
CODE_BUILDER_VALIDATE_TOOL,
CODE_BUILDER_VALIDATE_NODE_TOOL,
} from './constants';
import { LIST_N8N_CONNECT_SERVICES_TOOL_NAME } from '../../mcp.constants';
export function getMcpInstructions(isBuilderEnabled: boolean): string {
export function getMcpInstructions(
isBuilderEnabled: boolean,
isN8nConnectAvailable = false,
): string {
const INTRO = 'This is the official MCP server for n8n, a workflow automation platform.';
const N8N_CONNECT_HINT = isN8nConnectAvailable
? `
Explore nodes covered by n8n credits when the user has not specified a particular integration. n8n credits let users consume LLMs and third-party services directly through n8n with usage-based billing, so they can skip credential setup. Discovery tools (${CODE_BUILDER_SEARCH_NODES_TOOL.toolName}, ${CODE_BUILDER_GET_NODE_TYPES_TOOL.toolName}) and list_credentials return an optional \`n8nConnect.nodes\` array when the instance has n8n credits available. Nodes in that array can attach a managed credential automatically — the workflow runs without the user configuring keys. If the user asked for a specific integration or none of the covered nodes fit, use the requested integration with regular credentials. Call ${LIST_N8N_CONNECT_SERVICES_TOOL_NAME} if you need details (per-node supported resource+operation combos, min type versions, hidden properties).`
: '';
const BUILDER_INSTRUCTIONS = `This MCP server provides tools to build n8n workflows programmatically using the n8n Workflow SDK.
To build n8n workflows, follow these steps in order:
@@ -30,7 +40,7 @@ To build n8n workflows, follow these steps in order:
2. Get workflow best practices: You MUST call ${MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName} for each workflow technique relevant to the user's request (e.g. "chatbot", "scheduling", "triage"). Call once per technique. Use the returned design guidance, recommended nodes, and common pitfalls to decide which nodes and patterns to use. If you are unsure which techniques apply, call this tool with technique="list" first to see all available techniques.
3. Discover nodes: Call ${CODE_BUILDER_SEARCH_NODES_TOOL.toolName} with queries for services you need (e.g., ["gmail", "slack", "schedule trigger"]), utility nodes (e.g., ["set", "if", "merge", "code"]), and suggested nodes you plan to use. Note the discriminators (resource/operation/mode) in the results.
3. Discover nodes: Call ${CODE_BUILDER_SEARCH_NODES_TOOL.toolName} with queries for services you need (e.g., ["gmail", "slack", "schedule trigger"]), utility nodes (e.g., ["set", "if", "merge", "code"]), and suggested nodes you plan to use. Note the discriminators (resource/operation/mode) in the results.${N8N_CONNECT_HINT}
4. Get type definitions: Call ${CODE_BUILDER_GET_NODE_TYPES_TOOL.toolName} with ALL node IDs you plan to use, including discriminators from search results. This returns the exact TypeScript parameter definitions. DO NOT skip this — guessing parameter names creates invalid workflows.
@@ -2,11 +2,20 @@ import type { User } from '@n8n/db';
import z from 'zod';
import type { NodeCatalogService } from '@/node-catalog';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { Telemetry } from '@/telemetry';
import { CODE_BUILDER_SEARCH_NODES_TOOL } from './constants';
import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types';
import { toN8nConnectCoverage } from '../../mcp-ai-gateway.helper';
import {
LIST_N8N_CONNECT_SERVICES_TOOL_NAME,
USER_CALLED_MCP_TOOL_EVENT,
} from '../../mcp.constants';
import type {
N8nConnectCoverage,
ToolDefinition,
UserCalledMCPToolEventPayload,
} from '../../mcp.types';
const inputSchema = {
queries: z
@@ -21,6 +30,19 @@ const outputSchema = {
results: z
.string()
.describe('Search results with matching node IDs, discriminators, and related nodes'),
n8nConnect: z
.object({
credentialTypes: z.array(z.string()).describe('Credential types n8n Connect can provide.'),
nodes: z
.array(z.string())
.describe(
'Node types n8n Connect may cover. Prefer these when the user has not specified an integration. Candidate coverage only — exact eligibility also depends on the node action, minimum type version, and hidden properties.',
),
})
.optional()
.describe(
`Present when n8n Connect is available. Candidate coverage — cross-reference against the search results, but call ${LIST_N8N_CONNECT_SERVICES_TOOL_NAME} for exact eligibility (supported actions, min versions, hidden properties).`,
),
} satisfies z.ZodRawShape;
/**
@@ -31,6 +53,7 @@ export const createSearchWorkflowNodesTool = (
user: User,
nodeCatalogService: NodeCatalogService,
telemetry: Telemetry,
aiGatewayService: AiGatewayService,
): ToolDefinition<typeof inputSchema> => ({
name: CODE_BUILDER_SEARCH_NODES_TOOL.toolName,
config: {
@@ -54,7 +77,10 @@ export const createSearchWorkflowNodesTool = (
};
try {
const { results, queriesWithNoResults } = await nodeCatalogService.searchNodes(queries);
const [{ results, queriesWithNoResults }, availability] = await Promise.all([
nodeCatalogService.searchNodes(queries),
aiGatewayService.isAvailable(),
]);
telemetryPayload.results = {
success: true,
@@ -66,9 +92,20 @@ export const createSearchWorkflowNodesTool = (
};
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
const structured: {
results: string;
n8nConnect?: N8nConnectCoverage;
} = {
results,
};
const coverage = toN8nConnectCoverage(availability);
if (coverage) structured.n8nConnect = coverage;
const text = coverage ? `${results}\n\nn8nConnect: ${JSON.stringify(coverage)}` : results;
return {
content: [{ type: 'text', text: results }],
structuredContent: { results },
content: [{ type: 'text', text }],
structuredContent: structured,
};
} catch (error) {
telemetryPayload.results = {
@@ -10,7 +10,11 @@ import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.ty
import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check';
import { MCP_UPDATE_WORKFLOW_TOOL } from './constants';
import { validateCredentialReferences } from './credential-validation';
import { autoPopulateNodeCredentials } from './credentials-auto-assign';
import {
autoPopulateNodeCredentials,
trackAutoassignOutcomes,
type SlotOutcome,
} from './credentials-auto-assign';
import { validateDataTableReferencesForUpdate } from './data-table-validation';
import { sanitizeSkillsUsed, SKILLS_USED_PARAM_DESCRIPTION } from './skills-used';
import {
@@ -35,6 +39,7 @@ import type { WorkflowPublishedDataService } from '@/workflows/workflow-publishe
import type { DataTableUserOperations } from '@/modules/data-table/data-table-proxy.service';
import type { NodeTypes } from '@/node-types';
import type { TagService } from '@/services/tag.service';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { UrlService } from '@/services/url.service';
import type { Telemetry } from '@/telemetry';
import { resolveNodeWebhookIds } from '@/workflow-helpers';
@@ -244,6 +249,7 @@ const outputSchema = {
nodeName: z.string(),
credentialName: z.string(),
credentialType: z.string(),
source: z.enum(['user', 'aiGateway']).optional(),
}),
)
.optional()
@@ -447,6 +453,7 @@ export const createUpdateWorkflowTool = (
globalConfig: GlobalConfig,
subworkflowPolicyChecker: SubworkflowPolicyChecker,
workflowPublishedDataService: WorkflowPublishedDataService,
aiGatewayService: AiGatewayService,
): ToolDefinition<typeof inputSchema> => ({
name: MCP_UPDATE_WORKFLOW_TOOL.toolName,
config: {
@@ -662,8 +669,10 @@ export const createUpdateWorkflowTool = (
nodeName: string;
credentialName: string;
credentialType: string;
source?: 'user' | 'aiGateway';
}> = [];
let skippedHttpNodes: string[] = [];
let autoAssignOutcomes: SlotOutcome[] = [];
if (result.addedNodeNames.length > 0) {
const addedNodeSet = new Set(result.addedNodeNames);
@@ -675,9 +684,11 @@ export const createUpdateWorkflowTool = (
nodeTypes,
credentialsService,
workflowProjectId,
aiGatewayService,
);
credentialAssignments = autoAssign.assignments;
skippedHttpNodes = autoAssign.skippedHttpNodes;
autoAssignOutcomes = autoAssign.outcomes;
}
const { ParseValidateHandler } = await import('@n8n/ai-workflow-builder');
@@ -729,6 +740,18 @@ export const createUpdateWorkflowTool = (
...(tagIds !== undefined ? { tagIds } : {}),
});
if (autoAssignOutcomes.length > 0) {
const nodeTypesByName = new Map(updatedWorkflow.nodes.map((n) => [n.name, n.type]));
trackAutoassignOutcomes(
telemetry,
user.id,
'update_workflow',
autoAssignOutcomes,
nodeTypesByName,
workflowId,
);
}
void collaborationService.broadcastWorkflowUpdate(workflowId, user.id).catch(() => {});
const baseUrl = urlService.getInstanceBaseUrl();
+1 -4
View File
@@ -10,10 +10,7 @@ import { join, dirname } from 'path';
import { LoadNodesAndCredentials } from './load-nodes-and-credentials';
import { convertNodeToAiTool, convertNodeToHitlTool } from './tool-generation';
import { shouldAssignExecuteMethod } from './utils';
const stripToolSuffix = (nodeType: string) =>
nodeType.replace(/HitlTool$/, '').replace(/Tool$/, '');
import { shouldAssignExecuteMethod, stripToolSuffix } from './utils';
@Service()
export class NodeTypes implements INodeTypes {
@@ -138,6 +138,51 @@ describe('AiGatewayService', () => {
const service = makeService();
await expect(service.getGatewayConfig()).rejects.toThrow(UserError);
});
it('throws UserError when providerConfig is null', async () => {
requestMock.mockResolvedValueOnce(
ok({ nodes: [], credentialTypes: [], providerConfig: null }),
);
const service = makeService();
await expect(service.getGatewayConfig()).rejects.toThrow(UserError);
});
});
describe('isAvailable()', () => {
it('returns available:false when the AI Gateway is not licensed', async () => {
const service = makeService({ isAiGatewayLicensed: false });
const result = await service.isAvailable();
expect(result).toEqual({ available: false });
expect(requestMock).not.toHaveBeenCalled();
});
it('returns available:false when baseUrl is not configured', async () => {
const service = makeService({ baseUrl: null });
const result = await service.isAvailable();
expect(result).toEqual({ available: false });
});
it('returns available:false when the gateway request fails (fail open)', async () => {
requestMock.mockResolvedValueOnce(fail(503));
const service = makeService();
const result = await service.isAvailable();
expect(result).toEqual({ available: false });
});
it('returns available:true with config when licensed and gateway responds', async () => {
requestMock.mockResolvedValueOnce(ok(MOCK_GATEWAY_CONFIG));
const service = makeService();
const result = await service.isAvailable();
expect(result).toEqual({ available: true, config: MOCK_GATEWAY_CONFIG });
});
});
describe('getSyntheticCredential()', () => {
@@ -720,6 +765,49 @@ describe('AiGatewayService', () => {
dateSpy.mockRestore();
});
it('caches a failed fetch and does not re-fetch within the failure TTL', async () => {
requestMock.mockResolvedValue(fail(503));
const service = makeService();
const dateSpy = vi.spyOn(Date, 'now');
const now = 1_700_000_000_000;
dateSpy.mockReturnValue(now);
await expect(service.getGatewayConfig()).rejects.toThrow();
expect(requestMock).toHaveBeenCalledTimes(1);
// Within the 60s failure window — throttled, no new request
dateSpy.mockReturnValue(now + 30 * 1000);
await expect(service.getGatewayConfig()).rejects.toThrow();
expect(requestMock).toHaveBeenCalledTimes(1);
// Past the failure window — retries
dateSpy.mockReturnValue(now + 60 * 1000 + 1);
await expect(service.getGatewayConfig()).rejects.toThrow();
expect(requestMock).toHaveBeenCalledTimes(2);
dateSpy.mockRestore();
});
it('clears the failure throttle after a successful fetch', async () => {
const service = makeService();
const dateSpy = vi.spyOn(Date, 'now');
const now = 1_700_000_000_000;
dateSpy.mockReturnValue(now);
requestMock.mockResolvedValueOnce(fail(503));
await expect(service.getGatewayConfig()).rejects.toThrow();
expect(requestMock).toHaveBeenCalledTimes(1);
// Past the failure window — a retry succeeds and clears the marker
dateSpy.mockReturnValue(now + 60 * 1000 + 1);
requestMock.mockResolvedValueOnce(ok(MOCK_GATEWAY_CONFIG));
const result = await service.getGatewayConfig();
expect(result).toEqual(MOCK_GATEWAY_CONFIG);
expect(requestMock).toHaveBeenCalledTimes(2);
dateSpy.mockRestore();
});
});
describe('token cache size limit', () => {
+55 -18
View File
@@ -1,4 +1,4 @@
import type { AiGatewayConfigDto, AiGatewayUsageResponse } from '@n8n/api-types';
import { AiGatewayConfigDto, type AiGatewayUsageResponse } from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import { GlobalConfig } from '@n8n/config';
@@ -7,7 +7,7 @@ import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import type { ICredentialDataDecryptedObject, IHttpRequestMethods } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
import { OperationalError, UserError } from 'n8n-workflow';
import { N8N_VERSION, AI_ASSISTANT_SDK_VERSION } from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
@@ -25,6 +25,10 @@ interface GatewayWalletResponse {
balance: number;
}
export type AiGatewayAvailability =
| { available: true; config: AiGatewayConfigDto }
| { available: false };
@Service()
export class AiGatewayService {
private readonly tokenCache = new Map<
@@ -39,6 +43,13 @@ export class AiGatewayService {
private configFetchedAt = 0;
private static readonly CONFIG_TTL_MS = 60 * 60 * 1000; // 1 hour
/**
* Timestamp of the last failed config fetch. A failure is cached briefly so a
* down gateway isn't re-hit on every `isAvailable()` call (fired per MCP tool call).
*/
private configFetchFailedAt = 0;
private static readonly CONFIG_FAILURE_TTL_MS = 60 * 1000; // 1 minute
private static readonly GATEWAY_PATH_PREFIX = '/v1/gateway';
constructor(
@@ -286,29 +297,55 @@ export class AiGatewayService {
);
}
/**
* Returns `{ available: true, config }` when the AI Gateway is both licensed
* AND its config fetches successfully; `{ available: false }` otherwise.
* Never propagates gateway or config errors.
*/
async isAvailable(): Promise<AiGatewayAvailability> {
if (!this.licenseState.isAiGatewayLicensed()) return { available: false };
try {
const config = await this.getGatewayConfig();
return { available: true, config };
} catch {
return { available: false };
}
}
async getGatewayConfig(): Promise<AiGatewayConfigDto> {
if (!this.isConfigStale()) return this.gatewayConfig!;
const baseUrl = this.requireBaseUrl();
const data = await this.gatewayRequest<AiGatewayConfigDto>(
{
method: 'GET',
url: `${baseUrl}/v1/gateway/config`,
},
'Failed to fetch AI Gateway config',
);
// Throttle re-fetching after a recent failure so a down gateway isn't hit on every call.
if (
!Array.isArray(data.nodes) ||
!Array.isArray(data.credentialTypes) ||
typeof data.providerConfig !== 'object'
this.configFetchFailedAt > 0 &&
Date.now() - this.configFetchFailedAt < AiGatewayService.CONFIG_FAILURE_TTL_MS
) {
throw new UserError('AI Gateway returned an invalid config response.');
throw new OperationalError('AI Gateway config fetch recently failed; retry is throttled.');
}
this.gatewayConfig = data;
this.configFetchedAt = Date.now();
return data;
const baseUrl = this.requireBaseUrl();
try {
const data = await this.gatewayRequest<unknown>(
{
method: 'GET',
url: `${baseUrl}/v1/gateway/config`,
},
'Failed to fetch AI Gateway config',
);
const parsed = AiGatewayConfigDto.safeParse(data);
if (!parsed.success) {
throw new UserError('AI Gateway returned an invalid config response.');
}
this.gatewayConfig = parsed.data;
this.configFetchedAt = Date.now();
this.configFetchFailedAt = 0;
return parsed.data;
} catch (error) {
this.configFetchFailedAt = Date.now();
throw error;
}
}
/**
+9
View File
@@ -11,6 +11,15 @@ export function isWorkflowIdValid(id: string | null | undefined): boolean {
return typeof id === 'string' && id.length > 0 && id.length <= 21;
}
/**
* Strips the "Tool"/"HitlTool" suffix from a tool-variant node type (e.g. `openAiTool`
* `openAi`), yielding the base node type. Kept in sync with the editor-ui
* `stripToolSuffix` so backend and frontend agree on the lookup fallback.
*/
export function stripToolSuffix(nodeType: string): string {
return nodeType.replace(/HitlTool$/, '').replace(/Tool$/, '');
}
function findWorkflowStart(executionMode: 'integrated' | 'cli') {
return function (nodes: INode[]) {
const executeWorkflowTriggerNode = nodes.find(