feat(core): Expose agent test execution through MCP (#35744)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann 2026-08-07 15:19:54 +02:00 committed by GitHub
parent 9d86e36ab9
commit 6f761ad917
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 934 additions and 117 deletions

View File

@ -16,7 +16,7 @@ export const MCP_APPS_VARIANT_ENABLED = 'variant';
// current behaviour.
export const MCP_CANVAS_GROUPS_FLAG = '102_mcp_canvas_groups';
export const MCP_AGENT_SCOPES = ['agent:read', 'agent:write'] as const;
export const MCP_AGENT_SCOPES = ['agent:read', 'agent:write', 'agent:execute'] as const;
/**
* OAuth scopes a user can grant to an MCP client on the consent screen for

View File

@ -1,4 +1,10 @@
import type { CredentialProvider, StreamChunk } from '@n8n/agents';
import {
zodToJsonSchema,
type CredentialProvider,
type SerializableAgentState,
type StreamChunk,
} from '@n8n/agents';
import { APPROVAL_RESUME_SCHEMA } from '@n8n/agents/tool';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
@ -7,16 +13,52 @@ import type { AgentExecutionService } from '../agent-execution.service';
import { AgentTestRunService } from '../agent-test-run.service';
import type { AgentValidationService } from '../agent-validation.service';
import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
const agentId = 'agent-1';
const projectId = 'project-1';
const user = mock<User>({ id: 'user-1' });
const credentialProvider = mock<CredentialProvider>();
const approvalResumeSchema = (() => {
const schema = zodToJsonSchema(APPROVAL_RESUME_SCHEMA);
if (schema === null) throw new Error('Failed to generate approval resume schema');
return schema;
})();
function suspendedApprovalCheckpoint(
resumeSchema: typeof approvalResumeSchema = approvalResumeSchema,
persistence: SerializableAgentState['persistence'] = {
threadId: 'session-1',
resourceId: 'draft-chat:user-1',
},
): SerializableAgentState {
return {
status: 'suspended',
persistence,
messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] },
pendingToolCalls: {
'tool-call-1': {
runId: 'run-1',
toolCallId: 'tool-call-1',
toolName: 'delete_record',
input: { id: 'record-1' },
suspended: true,
suspendPayload: {
type: 'approval',
toolName: 'delete_record',
args: { id: 'record-1' },
},
resumeSchema,
},
},
};
}
function makeService() {
const agentExecutionService = mock<AgentExecutionService>();
const agentValidationService = mock<AgentValidationService>();
const agentExecutionOrchestratorService = mock<AgentExecutionOrchestratorService>();
const n8nCheckpointStorage = mock<N8NCheckpointStorage>();
agentExecutionService.findThreadById.mockResolvedValue(null);
agentValidationService.validateAgentIsRunnable.mockResolvedValue({ missing: [] });
@ -25,10 +67,12 @@ function makeService() {
agentExecutionService,
agentValidationService,
agentExecutionOrchestratorService,
n8nCheckpointStorage,
),
agentExecutionService,
agentValidationService,
agentExecutionOrchestratorService,
n8nCheckpointStorage,
};
}
@ -126,7 +170,8 @@ describe('AgentTestRunService', () => {
});
it('resumes the same draft session and returns the next suspended segment', async () => {
const { service, agentExecutionOrchestratorService } = makeService();
const { service, agentExecutionOrchestratorService, n8nCheckpointStorage } = makeService();
n8nCheckpointStorage.load.mockResolvedValue(suspendedApprovalCheckpoint());
agentExecutionOrchestratorService.resumeForChat.mockImplementation(async function* (config) {
config.onExecutionRecorded?.('execution-2');
yield { type: 'text-delta', id: 'text-1', delta: ' Next step.' };
@ -143,16 +188,18 @@ describe('AgentTestRunService', () => {
};
});
const result = await service.resumeDraftRun({
const result = await service.resumeDraftApproval({
agentId,
projectId,
sessionId: 'session-1',
runId: 'run-1',
toolCallId: 'tool-call-1',
resumeData: { approved: true },
continuation: {
sessionId: 'session-1',
runId: 'run-1',
toolCallId: 'tool-call-1',
response: 'First step.',
},
approved: true,
user,
source: 'instance-ai',
response: 'First step.',
});
expect(result).toEqual({
@ -188,6 +235,63 @@ describe('AgentTestRunService', () => {
);
});
it.each([
['missing', undefined],
['noncanonical', suspendedApprovalCheckpoint({ type: 'object' })],
[
'wrong-memory',
suspendedApprovalCheckpoint(approvalResumeSchema, {
threadId: 'session-1',
resourceId: 'draft-chat:another-user',
}),
],
])('rejects a %s approval checkpoint before resuming', async (_label, checkpoint) => {
const { service, agentExecutionOrchestratorService, n8nCheckpointStorage } = makeService();
n8nCheckpointStorage.load.mockResolvedValue(checkpoint);
await expect(
service.resumeDraftApproval({
agentId,
projectId,
continuation: {
sessionId: 'session-1',
runId: 'run-1',
toolCallId: 'tool-call-1',
response: '',
},
approved: false,
user,
}),
).rejects.toThrow('This test run can no longer be resumed.');
expect(agentExecutionOrchestratorService.resumeForChat).not.toHaveBeenCalled();
});
it('deduplicates suspended run cancellation and reports partial failure', async () => {
const { service, agentExecutionOrchestratorService } = makeService();
agentExecutionOrchestratorService.cancelChatRun
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
await expect(
service.cancelSuspendedRuns({
agentId,
userId: user.id,
suspensions: [{ runId: 'run-1' }, { runId: 'run-1' }, { runId: 'run-2' }],
}),
).resolves.toBe(false);
expect(agentExecutionOrchestratorService.cancelChatRun).toHaveBeenCalledTimes(2);
expect(agentExecutionOrchestratorService.cancelChatRun).toHaveBeenNthCalledWith(1, {
agentId,
runId: 'run-1',
resourceId: 'draft-chat:user-1',
});
expect(agentExecutionOrchestratorService.cancelChatRun).toHaveBeenNthCalledWith(2, {
agentId,
runId: 'run-2',
resourceId: 'draft-chat:user-1',
});
});
it('rejects a session owned by another agent without starting a run', async () => {
const {
service,

View File

@ -1937,7 +1937,7 @@ describe('AgentsBuilderToolsService', () => {
response: 'Deletion approved. I need approval to notify the owner.',
};
const { service, agentTestRunService } = makeService();
agentTestRunService.resumeDraftRun.mockResolvedValue({
agentTestRunService.resumeDraftApproval.mockResolvedValue({
status: 'suspended',
response: secondContinuation.response,
sessionId: secondContinuation.sessionId,
@ -1966,16 +1966,13 @@ describe('AgentsBuilderToolsService', () => {
);
const secondSuspensions = suspendedChunks(await collectChunks(resumedRun.stream));
expect(agentTestRunService.resumeDraftRun).toHaveBeenCalledWith({
expect(agentTestRunService.resumeDraftApproval).toHaveBeenCalledWith({
agentId,
projectId,
sessionId: firstContinuation.sessionId,
runId: firstContinuation.runId,
toolCallId: firstContinuation.toolCallId,
resumeData: { approved: true },
continuation: firstContinuation,
approved: true,
user,
source: 'instance-ai',
response: firstContinuation.response,
abortSignal: expect.any(AbortSignal),
});
expect(secondSuspensions).toHaveLength(1);
@ -2019,13 +2016,19 @@ describe('AgentsBuilderToolsService', () => {
},
],
});
agentTestRunService.cancelSuspendedRun.mockResolvedValue(true);
agentTestRunService.cancelSuspendedRuns.mockResolvedValue(true);
const result = await getCallAgentTool(service).handler!({ message: 'Schedule it' }, ctx);
expect(agentTestRunService.cancelSuspendedRun).toHaveBeenCalledWith({
expect(agentTestRunService.cancelSuspendedRuns).toHaveBeenCalledWith({
agentId,
runId: 'run-1',
suspensions: [
expect.objectContaining({
runId: 'run-1',
toolCallId: 'tool-call-1',
toolName: 'schedule_record',
}),
],
userId: 'user-1',
});
expect(result).toEqual({

View File

@ -1,13 +1,27 @@
import type { CredentialProvider, StreamChunk } from '@n8n/agents';
import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import {
zodToJsonSchema,
type CredentialProvider,
type SerializableAgentState,
type StreamChunk,
} from '@n8n/agents';
import {
APPROVAL_RESUME_SCHEMA,
APPROVAL_SUSPEND_SCHEMA,
type ApprovalSuspendPayload,
} from '@n8n/agents/tool';
import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { randomUUID } from 'node:crypto';
import { UserError } from 'n8n-workflow';
import { z } from 'zod';
import type { StoredAttachmentRef } from './agent-chat-attachment.service';
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
import { AgentExecutionService, threadBelongsTo } from './agent-execution.service';
import { AgentValidationService } from './agent-validation.service';
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
interface PrepareDraftRunInput {
@ -54,6 +68,17 @@ interface ResumeDraftRunInput {
abortSignal?: AbortSignal;
}
export const agentTestRunContinuationSchema = z
.object({
runId: z.string(),
toolCallId: z.string(),
sessionId: z.string(),
response: z.string(),
})
.strict();
export type AgentTestRunContinuation = z.infer<typeof agentTestRunContinuationSchema>;
export interface AgentTestRunSuspension {
runId: string;
toolCallId: string;
@ -63,6 +88,10 @@ export interface AgentTestRunSuspension {
resumeSchema?: unknown;
}
export interface AgentTestRunApproval extends ApprovalSuspendPayload {
continuation: AgentTestRunContinuation;
}
export type AgentTestRunResult =
| { status: 'completed'; response: string; sessionId: string; executionId?: string }
| {
@ -77,12 +106,56 @@ export type AgentTestRunResult =
type CollectedDraftRunResult = Extract<AgentTestRunResult, { status: 'completed' | 'suspended' }>;
const expectedApprovalResumeJsonSchema = zodToJsonSchema(APPROVAL_RESUME_SCHEMA);
export class InvalidAgentTestRunCheckpointError extends UserError {
readonly code = 'invalid_checkpoint';
constructor() {
super('This test run can no longer be resumed.');
}
}
export function parseStandardApprovalSuspension(
suspension: AgentTestRunSuspension,
): ApprovalSuspendPayload | undefined {
const payload = APPROVAL_SUSPEND_SCHEMA.safeParse(suspension.suspendPayload);
if (
!payload.success ||
!isDeepStrictEqual(suspension.resumeSchema, expectedApprovalResumeJsonSchema)
) {
return undefined;
}
return payload.data;
}
export function collectStandardApprovals(
result: Extract<AgentTestRunResult, { status: 'suspended' }>,
): AgentTestRunApproval[] | undefined {
const approvals: AgentTestRunApproval[] = [];
for (const suspension of result.suspensions) {
const approval = parseStandardApprovalSuspension(suspension);
if (!approval) return undefined;
approvals.push({
...approval,
continuation: {
runId: suspension.runId,
toolCallId: suspension.toolCallId,
sessionId: result.sessionId,
response: result.response,
},
});
}
return approvals;
}
@Service()
export class AgentTestRunService {
constructor(
private readonly agentExecutionService: AgentExecutionService,
private readonly agentValidationService: AgentValidationService,
private readonly agentExecutionOrchestratorService: AgentExecutionOrchestratorService,
private readonly n8nCheckpointStorage: N8NCheckpointStorage,
) {}
async prepareDraftRun({
@ -181,20 +254,85 @@ export class AgentTestRunService {
return await this.collectDraftRun(stream, input.sessionId, input.response, () => executionId);
}
async cancelSuspendedRun({
async resumeDraftApproval(input: {
agentId: string;
projectId: string;
continuation: unknown;
approved: boolean;
user: User;
source?: string;
abortSignal?: AbortSignal;
}): Promise<AgentTestRunResult> {
const continuation = agentTestRunContinuationSchema.safeParse(input.continuation);
if (!continuation.success) throw new InvalidAgentTestRunCheckpointError();
let checkpoint: SerializableAgentState | undefined;
try {
checkpoint = await this.n8nCheckpointStorage.load(continuation.data.runId, input.agentId);
} catch (error) {
if (error instanceof UserError) throw new InvalidAgentTestRunCheckpointError();
throw error;
}
const pendingToolCall = checkpoint?.pendingToolCalls[continuation.data.toolCallId];
const expectedResourceId = draftChatMemoryResourceId(input.user.id);
if (
checkpoint?.status !== 'suspended' ||
checkpoint.persistence?.delegated === true ||
checkpoint.persistence?.threadId !== continuation.data.sessionId ||
checkpoint.persistence?.resourceId !== expectedResourceId ||
!pendingToolCall?.suspended ||
pendingToolCall.runId !== continuation.data.runId ||
!parseStandardApprovalSuspension({
runId: pendingToolCall.runId,
toolCallId: pendingToolCall.toolCallId,
toolName: pendingToolCall.toolName,
suspendPayload: pendingToolCall.suspendPayload,
resumeSchema: pendingToolCall.resumeSchema,
})
) {
throw new InvalidAgentTestRunCheckpointError();
}
return await this.resumeDraftRun({
agentId: input.agentId,
projectId: input.projectId,
sessionId: continuation.data.sessionId,
runId: continuation.data.runId,
toolCallId: continuation.data.toolCallId,
resumeData: { approved: input.approved },
user: input.user,
source: input.source,
response: continuation.data.response,
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
});
}
async cancelSuspendedRuns({
agentId,
runId,
suspensions,
userId,
}: {
agentId: string;
runId: string;
suspensions: Array<Pick<AgentTestRunSuspension, 'runId'>>;
userId: string;
}): Promise<boolean> {
return await this.agentExecutionOrchestratorService.cancelChatRun({
agentId,
runId,
resourceId: draftChatMemoryResourceId(userId),
});
const runIds = [...new Set(suspensions.map(({ runId }) => runId))];
try {
const cancellations = await Promise.all(
runIds.map(
async (runId) =>
await this.agentExecutionOrchestratorService.cancelChatRun({
agentId,
runId,
resourceId: draftChatMemoryResourceId(userId),
}),
),
);
return cancellations.every(Boolean);
} catch {
return false;
}
}
private async collectDraftRun(

View File

@ -1,7 +1,5 @@
import { isDeepStrictEqual } from 'node:util';
import {
isAbortError,
zodToJsonSchema,
type BuiltTool,
type CredentialProvider,
type InterruptibleToolContext,
@ -62,8 +60,9 @@ import { AgentSkillsService } from '../agent-skills.service';
import { AgentTaskService } from '../agent-task.service';
import {
AgentTestRunService,
collectStandardApprovals,
InvalidAgentTestRunCheckpointError,
type AgentTestRunResult,
type AgentTestRunSuspension,
} from '../agent-test-run.service';
import { AgentsToolsService } from '../agents-tools.service';
import { AgentsService } from '../agents.service';
@ -98,30 +97,6 @@ const STALE_CONFIG_ERROR: ConfigValidationError = {
'Agent config changed since you last read it. Call read_config, then retry using the config and configHash it returns.',
};
const callAgentContinuationSchema = z
.object({
runId: z.string(),
toolCallId: z.string(),
sessionId: z.string(),
response: z.string(),
})
.strict();
const expectedApprovalResumeJsonSchema = zodToJsonSchema(APPROVAL_RESUME_SCHEMA);
function parseStandardApprovalSuspension(
suspension: AgentTestRunSuspension,
): ApprovalSuspendPayload | undefined {
const payload = APPROVAL_SUSPEND_SCHEMA.safeParse(suspension.suspendPayload);
if (
!payload.success ||
!isDeepStrictEqual(suspension.resumeSchema, expectedApprovalResumeJsonSchema)
) {
return undefined;
}
return payload.data;
}
/** LLM-facing follow-up guidance for this builder surface (CLI skill-based tools). */
const CLI_AGENT_CONFIG_MESSAGES: AgentConfigValidationMessages = {
emptyInstructionsFollowUp: 'saving the config again.',
@ -704,27 +679,13 @@ export class AgentsBuilderToolsService {
...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}),
});
} else {
const continuation = callAgentContinuationSchema.safeParse(ctx.continuation);
if (
!continuation.success ||
!APPROVAL_SUSPEND_SCHEMA.safeParse(ctx.suspendPayload).success
) {
return {
status: 'error',
code: 'invalid_checkpoint',
message: 'This test run can no longer be resumed.',
};
}
result = await this.agentTestRunService.resumeDraftRun({
result = await this.agentTestRunService.resumeDraftApproval({
agentId,
projectId,
sessionId: continuation.data.sessionId,
runId: continuation.data.runId,
toolCallId: continuation.data.toolCallId,
resumeData: { approved: ctx.resumeData.approved },
continuation: ctx.continuation,
approved: ctx.resumeData.approved,
user,
source: 'instance-ai',
response: continuation.data.response,
...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}),
});
}
@ -746,37 +707,19 @@ export class AgentsBuilderToolsService {
}
if (result.status === 'completed') return result;
const approvals = result.suspensions.map(parseStandardApprovalSuspension);
if (approvals.every((approval) => approval !== undefined)) {
const firstSuspension = result.suspensions[0];
const firstApproval = approvals[0];
if (firstSuspension && firstApproval) {
return await ctx.suspend(firstApproval, {
continuation: {
runId: firstSuspension.runId,
toolCallId: firstSuspension.toolCallId,
sessionId: result.sessionId,
response: result.response,
},
});
}
const approvals = collectStandardApprovals(result);
const firstApproval = approvals?.[0];
if (firstApproval) {
const { continuation, ...approval } = firstApproval;
return await ctx.suspend(approval, { continuation });
}
const runIds = [...new Set(result.suspensions.map(({ runId }) => runId))];
const cancellations = await Promise.all(
runIds.map(
async (runId) =>
await this.agentTestRunService.cancelSuspendedRun({
agentId,
runId,
userId: user.id,
}),
),
).catch(() => []);
if (
cancellations.length !== runIds.length ||
cancellations.some((cancelled) => !cancelled)
) {
const cancelled = await this.agentTestRunService.cancelSuspendedRuns({
agentId,
suspensions: result.suspensions,
userId: user.id,
});
if (!cancelled) {
return {
status: 'error',
code: 'cancellation_failed',
@ -801,6 +744,13 @@ export class AgentsBuilderToolsService {
};
} catch (error) {
if (ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(error)) throw error;
if (error instanceof InvalidAgentTestRunCheckpointError) {
return {
status: 'error',
code: error.code,
message: error.message,
};
}
return {
status: 'error',
code: 'execution_failed',

View File

@ -1,3 +1,5 @@
import { zodToJsonSchema } from '@n8n/agents';
import { APPROVAL_RESUME_SCHEMA } from '@n8n/agents/tool';
import type { AgentJsonConfig } from '@n8n/api-types';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
@ -11,7 +13,8 @@ vi.mock('@/permissions.ee/check-access', () => ({
userHasScopes: vi.fn(),
}));
vi.mock('@n8n/agents', () => ({
vi.mock('@n8n/agents', async (importOriginal) => ({
...(await importOriginal<typeof import('@n8n/agents')>()),
fetchProviderCatalog: vi.fn().mockResolvedValue({
openai: { id: 'openai', name: 'OpenAI', models: { 'gpt-a': {}, 'gpt-b': {} } },
'not-offered': { id: 'not-offered', name: 'Not Offered', models: { x: {} } },
@ -35,6 +38,10 @@ import type { AgentRuntimeCacheService } from '@/modules/agents/agent-runtime-ca
import type { AgentSetupCompletionService } from '@/modules/agents/agent-setup-completion.service';
import { AgentSkillsService } from '@/modules/agents/agent-skills.service';
import { AgentTaskService } from '@/modules/agents/agent-task.service';
import {
AgentTestRunService,
InvalidAgentTestRunCheckpointError,
} from '@/modules/agents/agent-test-run.service';
import { AgentValidationService } from '@/modules/agents/agent-validation.service';
import { AgentsService } from '@/modules/agents/agents.service';
import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service';
@ -69,7 +76,7 @@ type ToolResult = {
type RegisteredTool = {
config: { description?: string; annotations?: Record<string, unknown> };
handler: (input: Record<string, unknown>) => Promise<ToolResult>;
handler: (input: Record<string, unknown>, extra: { signal: AbortSignal }) => Promise<ToolResult>;
};
const user = Object.assign(new User(), { id: 'user-1' });
@ -84,6 +91,20 @@ const baseConfig: AgentJsonConfig = {
// What configFromEntity(agentEntity()) composes: schema + integrations.
const composedConfig: AgentJsonConfig = { ...baseConfig, integrations: [] };
const standardApprovalResumeSchema = zodToJsonSchema(APPROVAL_RESUME_SCHEMA);
const approvalSuspension = (
runId: string,
toolCallId: string,
toolName: string,
args: Record<string, unknown>,
) => ({
runId,
toolCallId,
toolName,
input: args,
suspendPayload: { type: 'approval', toolName, args },
resumeSchema: standardApprovalResumeSchema,
});
const agentEntity = (overrides: Record<string, unknown> = {}): Agent =>
({
@ -109,6 +130,7 @@ describe('McpAgentToolsService', () => {
const agentPublishService = mockInstance(AgentPublishService);
const agentSkillsService = mockInstance(AgentSkillsService);
const agentTaskService = mockInstance(AgentTaskService);
const agentTestRunService = mockInstance(AgentTestRunService);
const agentCustomToolsService = mockInstance(AgentCustomToolsService);
const agentSecureRuntime = mockInstance(AgentSecureRuntime);
const integrationPersistenceService = mockInstance(AgentIntegrationPersistenceService);
@ -128,6 +150,7 @@ describe('McpAgentToolsService', () => {
agentPublishService,
agentSkillsService,
agentTaskService,
agentTestRunService,
agentCustomToolsService,
agentSecureRuntime,
integrationPersistenceService,
@ -167,10 +190,14 @@ describe('McpAgentToolsService', () => {
service.registerTools(registerTool, registerResource, user);
});
const callTool = async (name: string, input: Record<string, unknown>): Promise<ToolResult> => {
const callTool = async (
name: string,
input: Record<string, unknown>,
signal: AbortSignal = new AbortController().signal,
): Promise<ToolResult> => {
const tool = tools.get(name);
if (!tool) throw new Error(`Tool "${name}" is not registered`);
return await tool.handler(input);
return await tool.handler(input, { signal });
};
const useRealCustomToolPersistence = (agent: Agent) => {
@ -233,6 +260,7 @@ describe('McpAgentToolsService', () => {
it('registers all agent tools and the reference resource', () => {
expect([...tools.keys()].sort()).toEqual(
[
'call_agent',
'create_agent',
'delete_agent',
'discover_agent_assets',
@ -284,6 +312,35 @@ describe('McpAgentToolsService', () => {
expect(resource).toHaveBeenCalledTimes(1);
});
it('validates without suggesting an unavailable call_agent tool', async () => {
agentConfigService.validateConfig.mockResolvedValue({ valid: true, config: baseConfig });
agentValidationService.validateLoadedAgentConfiguration.mockResolvedValue({
status: 'valid',
issues: [],
} as never);
const { tools: filteredTools } = registerFiltered(
new Set<string>(TOOLS_BY_SCOPE['agent:read']),
);
const validateAgent = filteredTools.get('validate_agent');
if (!validateAgent) throw new Error('validate_agent is not registered');
const result = await validateAgent.handler(
{ agentId: 'agent-1' },
{ signal: new AbortController().signal },
);
expect(result.structuredContent).toEqual({
ok: true,
valid: true,
errors: [],
missing: [],
url: 'https://n8n.test/projects/project-1/agents/agent-1',
});
expect(userHasScopesMock).not.toHaveBeenCalledWith(user, ['agent:execute'], false, {
projectId: 'project-1',
});
});
it('registers no tools and no resource for an empty allow-list', () => {
const { tools: filteredTools, resource } = registerFiltered(new Set());
@ -316,6 +373,7 @@ describe('McpAgentToolsService', () => {
},
],
['validate_agent', 'agent:read', identity],
['call_agent', 'agent:execute', { ...identity, request: { type: 'message', message: 'Hi' } }],
['publish_agent', 'agent:publish', identity],
['unpublish_agent', 'agent:unpublish', identity],
['revert_agent', 'agent:update', identity],
@ -365,6 +423,7 @@ describe('McpAgentToolsService', () => {
{ ...identity, baseConfigHash: 'hash', operation: { type: 'config.replace', config: {} } },
],
['validate_agent', identity],
['call_agent', { ...identity, request: { type: 'message', message: 'Hi' } }],
['publish_agent', identity],
['unpublish_agent', identity],
['revert_agent', identity],
@ -998,6 +1057,34 @@ describe('McpAgentToolsService', () => {
errors: [],
missing: [],
url: 'https://n8n.test/projects/project-1/agents/agent-1',
nextStep: {
tool: 'call_agent',
reason: expect.any(String),
},
});
});
it('omits call_agent when the user cannot execute the agent', async () => {
userHasScopesMock.mockImplementation(
async (_user, scopes) => !scopes.includes('agent:execute'),
);
agentConfigService.validateConfig.mockResolvedValue({ valid: true, config: baseConfig });
agentValidationService.validateLoadedAgentConfiguration.mockResolvedValue({
status: 'valid',
issues: [],
} as never);
const result = await callTool('validate_agent', { agentId: 'agent-1' });
expect(result.structuredContent).toEqual({
ok: true,
valid: true,
errors: [],
missing: [],
url: 'https://n8n.test/projects/project-1/agents/agent-1',
});
expect(userHasScopesMock).toHaveBeenCalledWith(user, ['agent:execute'], false, {
projectId: 'project-1',
});
});
@ -1023,6 +1110,308 @@ describe('McpAgentToolsService', () => {
errors: ['model is required'],
missing: ['credential', 'model'],
});
expect(result.structuredContent).not.toHaveProperty('nextStep');
expect(userHasScopesMock).not.toHaveBeenCalledWith(user, ['agent:execute'], false, {
projectId: 'project-1',
});
});
});
describe('call_agent', () => {
it('starts and continues a draft session with MCP execution context', async () => {
userHasScopesMock.mockImplementation(async (_user, scopes) =>
scopes.includes('agent:execute'),
);
agentTestRunService.executeDraftRun
.mockResolvedValueOnce({
status: 'completed',
response: 'Hello',
sessionId: 'session-1',
executionId: 'execution-1',
})
.mockResolvedValueOnce({
status: 'completed',
response: 'Welcome back',
sessionId: 'session-1',
executionId: 'execution-2',
});
const startSignal = new AbortController().signal;
const continueSignal = new AbortController().signal;
const started = await callTool(
'call_agent',
{
agentId: 'agent-1',
request: { type: 'message', message: 'Hi' },
},
startSignal,
);
const continued = await callTool(
'call_agent',
{
agentId: 'agent-1',
request: { type: 'message', message: 'Continue', sessionId: 'session-1' },
},
continueSignal,
);
expect(started.structuredContent).toEqual({
ok: true,
status: 'completed',
response: 'Hello',
sessionId: 'session-1',
executionId: 'execution-1',
});
expect(continued.structuredContent).toEqual({
ok: true,
status: 'completed',
response: 'Welcome back',
sessionId: 'session-1',
executionId: 'execution-2',
});
expect(agentTestRunService.executeDraftRun).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
agentId: 'agent-1',
projectId: 'project-1',
message: 'Hi',
sessionId: undefined,
user,
source: 'mcp',
abortSignal: startSignal,
}),
);
expect(agentTestRunService.executeDraftRun).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
message: 'Continue',
sessionId: 'session-1',
source: 'mcp',
abortSignal: continueSignal,
}),
);
});
it('returns every canonical approval and resumes one into a chained suspension', async () => {
const response = 'I need approval.';
const first = approvalSuspension('run-1', 'tool-call-1', 'delete_record', {
id: 'record-1',
});
const second = approvalSuspension('run-1', 'tool-call-2', 'notify_owner', {
id: 'owner-1',
});
agentTestRunService.executeDraftRun.mockResolvedValue({
status: 'suspended',
response,
sessionId: 'session-1',
executionId: 'execution-1',
suspensions: [first, second],
});
const suspended = await callTool('call_agent', {
agentId: 'agent-1',
request: { type: 'message', message: 'Delete it' },
});
const firstContinuation = {
runId: 'run-1',
toolCallId: 'tool-call-1',
sessionId: 'session-1',
response,
};
expect(suspended.structuredContent).toEqual({
ok: true,
status: 'suspended',
response,
sessionId: 'session-1',
executionId: 'execution-1',
approvals: [
{
type: 'approval',
toolName: 'delete_record',
args: { id: 'record-1' },
continuation: firstContinuation,
},
{
type: 'approval',
toolName: 'notify_owner',
args: { id: 'owner-1' },
continuation: {
...firstContinuation,
toolCallId: 'tool-call-2',
},
},
],
});
const chained = approvalSuspension('run-2', 'tool-call-3', 'archive_record', {
id: 'record-1',
});
agentTestRunService.resumeDraftApproval.mockResolvedValue({
status: 'suspended',
response: 'Deleted. Archive it too?',
sessionId: 'session-1',
suspensions: [chained],
});
const resumeSignal = new AbortController().signal;
const resumed = await callTool(
'call_agent',
{
agentId: 'agent-1',
request: {
type: 'approval',
approved: true,
continuation: firstContinuation,
},
},
resumeSignal,
);
expect(agentTestRunService.resumeDraftApproval).toHaveBeenCalledWith({
agentId: 'agent-1',
projectId: 'project-1',
continuation: firstContinuation,
approved: true,
user,
source: 'mcp',
abortSignal: resumeSignal,
});
expect(resumed.structuredContent).toEqual({
ok: true,
status: 'suspended',
response: 'Deleted. Archive it too?',
sessionId: 'session-1',
approvals: [
{
type: 'approval',
toolName: 'archive_record',
args: { id: 'record-1' },
continuation: {
runId: 'run-2',
toolCallId: 'tool-call-3',
sessionId: 'session-1',
response: 'Deleted. Archive it too?',
},
},
],
});
});
it.each([
{ access: 'full read', canOpenPreview: true },
{ access: 'execute only', canOpenPreview: false },
])(
'cancels mixed suspensions and hands the session off to Preview with $access access',
async ({ canOpenPreview }) => {
userHasScopesMock.mockImplementation(
async (_user, scopes) => canOpenPreview || scopes.includes('agent:execute'),
);
const suspensions = [
approvalSuspension('run-1', 'tool-call-1', 'delete_record', { id: 'record-1' }),
{
runId: 'run-2',
toolCallId: 'tool-call-2',
toolName: 'choose_date',
suspendPayload: { prompt: 'Which date?' },
resumeSchema: { type: 'object', properties: { date: { type: 'string' } } },
},
];
agentTestRunService.executeDraftRun.mockResolvedValue({
status: 'suspended',
response: 'Choose a date.',
sessionId: 'session-1',
executionId: 'execution-1',
suspensions,
});
agentTestRunService.cancelSuspendedRuns.mockResolvedValue(true);
const result = await callTool('call_agent', {
agentId: 'agent-1',
request: { type: 'message', message: 'Schedule it' },
});
expect(agentTestRunService.cancelSuspendedRuns).toHaveBeenCalledWith({
agentId: 'agent-1',
suspensions,
userId: 'user-1',
});
expect(result.structuredContent).toEqual({
ok: true,
status: 'approval_required',
response: 'Choose a date.',
sessionId: 'session-1',
executionId: 'execution-1',
suspensions: [
{ runId: 'run-1', toolCallId: 'tool-call-1', toolName: 'delete_record' },
{ runId: 'run-2', toolCallId: 'tool-call-2', toolName: 'choose_date' },
],
previewUrl: 'https://n8n.test/projects/project-1/agents/agent-1/preview',
...(canOpenPreview ? {} : { previewAccessNote: expect.any(String) }),
});
},
);
it('returns Preview when an unsupported suspension cannot be cancelled', async () => {
userHasScopesMock.mockImplementation(async (_user, scopes) =>
scopes.includes('agent:execute'),
);
agentTestRunService.executeDraftRun.mockResolvedValue({
status: 'suspended',
response: '',
sessionId: 'session-1',
suspensions: [
{
runId: 'run-1',
toolCallId: 'tool-call-1',
toolName: 'choose_date',
suspendPayload: { prompt: 'Which date?' },
},
],
});
agentTestRunService.cancelSuspendedRuns.mockResolvedValue(false);
const result = await callTool('call_agent', {
agentId: 'agent-1',
request: { type: 'message', message: 'Schedule it' },
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toMatchObject({
ok: false,
code: 'cancellation_failed',
sessionId: 'session-1',
previewUrl: 'https://n8n.test/projects/project-1/agents/agent-1/preview',
previewAccessNote: expect.any(String),
});
});
it('rejects an invalid approval continuation with a stable error', async () => {
agentTestRunService.resumeDraftApproval.mockRejectedValue(
new InvalidAgentTestRunCheckpointError(),
);
const result = await callTool('call_agent', {
agentId: 'agent-1',
request: {
type: 'approval',
approved: false,
continuation: {
runId: 'expired-run',
toolCallId: 'tool-call-1',
sessionId: 'session-1',
response: '',
},
},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
ok: false,
status: 'error',
code: 'invalid_checkpoint',
message: 'This test run can no longer be resumed.',
});
});
});

View File

@ -82,6 +82,10 @@ describe('getAllowedToolNames', () => {
expect(allowed).toContain('publish_agent');
expect(allowed).toContain('unpublish_agent');
});
it('grants only call_agent with agent:execute', () => {
expect(getAllowedToolNames(['agent:execute'])).toEqual(new Set(['call_agent']));
});
});
describe('McpService scope enforcement', () => {

View File

@ -68,6 +68,7 @@ export const TOOLS_BY_SCOPE: Record<McpScope, readonly string[]> = {
'publish_agent',
'unpublish_agent',
],
'agent:execute': ['call_agent'],
// 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', 'list_n8n_connect_services', 'explore_node_resources'],
@ -110,6 +111,7 @@ export const BUILDER_TOOLS: ReadonlySet<string> = new Set([
export const AGENT_TOOLS: ReadonlySet<string> = new Set([
...TOOLS_BY_SCOPE['agent:read'],
...TOOLS_BY_SCOPE['agent:write'],
...TOOLS_BY_SCOPE['agent:execute'],
]);
function isMcpScope(scope: string): scope is McpScope {

View File

@ -26,6 +26,7 @@ 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';
export const MCP_CALL_AGENT_TOOL_NAME = 'call_agent';
export const MCP_CREATE_AGENT_TOOL_NAME = 'create_agent';
/**

View File

@ -29,9 +29,9 @@ a Chat Trigger plus an AI Agent node for a requested n8n Agent.
## Build sequence
1. Use search_projects to identify the project, or search_agents and get_agent for an existing Agent.
By-ID tools (get_agent, mutate_agent, validate_agent, publish_agent, unpublish_agent,
revert_agent, list_agent_versions, delete_agent, update_agent_integration) take an agentId alone
and resolve the project from it.
By-ID tools (get_agent, mutate_agent, validate_agent, call_agent, publish_agent,
unpublish_agent, revert_agent, list_agent_versions, delete_agent, update_agent_integration) take
an agentId alone and resolve the project from it.
2. Use discover_agent_assets plus list_credentials, search_nodes, get_node_types, and
explore_node_resources to ground model, tool, workflow, integration, and credential choices.
3. For a new Agent, call create_agent with the initial config after discovering its assets. The
@ -41,13 +41,24 @@ a Chat Trigger plus an AI Agent node for a requested n8n Agent.
server-generated IDs.
5. Call validate_agent and resolve every reported error. A valid Agent is a completed draft; do not
publish it merely to finish the build.
6. Report that the draft is ready, include a clickable link using the \`url\` returned by
6. After validation succeeds, if call_agent is available and authorized, send one representative
message before reporting the draft ready. Otherwise, report the successfully validated draft
ready without a test run.
7. Report that the draft is ready, include a clickable link using the \`url\` returned by
validate_agent, and ask whether the user wants to publish it.
7. Call publish_agent only when the user explicitly requested publication, activation, deployment,
8. Call publish_agent only when the user explicitly requested publication, activation, deployment,
or making the Agent live, or confirms publication after the build.
8. Use update_agent_integration to configure chat integrations. Configuration never publishes the
9. Use update_agent_integration to configure chat integrations. Configuration never publishes the
Agent. A configured channel stays inactive until explicit publication unless the Agent already has an active version.
## Draft test runs
call_agent verifies the draft Agent's behavior through built-in Preview chat, not configured channel
triggers, platform context, message delivery, or replies. Real tools and credentials are used, so
side effects are possible. If the test exposes errors, report them and ask whether to fix them rather
than mutating the Agent automatically. Every approval decision must come from the human; resume each
returned approval individually.
## Publication approval
Building, editing, validating, or configuring an Agent never implies permission to publish or

View File

@ -37,6 +37,13 @@ import { AgentModelCatalogService } from '@/modules/agents/agent-model-catalog.s
import { AgentPublishService } from '@/modules/agents/agent-publish.service';
import { AgentSkillsService } from '@/modules/agents/agent-skills.service';
import { AgentTaskService } from '@/modules/agents/agent-task.service';
import {
AgentTestRunService,
agentTestRunContinuationSchema,
collectStandardApprovals,
InvalidAgentTestRunCheckpointError,
type AgentTestRunResult,
} from '@/modules/agents/agent-test-run.service';
import { AgentValidationService } from '@/modules/agents/agent-validation.service';
import { AgentsService } from '@/modules/agents/agents.service';
import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service';
@ -65,7 +72,11 @@ import {
AGENT_BUILDER_REFERENCE_URI,
AGENT_CONFIG_JSON_SCHEMA,
} from './agent-reference';
import { MCP_CREATE_AGENT_TOOL_NAME, USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants';
import {
MCP_CALL_AGENT_TOOL_NAME,
MCP_CREATE_AGENT_TOOL_NAME,
USER_CALLED_MCP_TOOL_EVENT,
} from '../../mcp.constants';
import type {
RegisterResourceFn,
RegisterToolFn,
@ -320,6 +331,28 @@ const updateIntegrationInput = {
.describe('Integration settings; required for Telegram connect operations'),
} satisfies z.ZodRawShape;
const callAgentRequestSchema = z.discriminatedUnion('type', [
z
.object({
type: z.literal('message'),
message: z.string().trim().min(1),
sessionId: z.string().trim().min(1).optional(),
})
.strict(),
z
.object({
type: z.literal('approval'),
approved: z.boolean(),
continuation: agentTestRunContinuationSchema,
})
.strict(),
]);
const callAgentInput = {
...agentIdentityShape,
request: callAgentRequestSchema,
} satisfies z.ZodRawShape;
const emptyInput = {} satisfies z.ZodRawShape;
type SearchAgentsInput = z.infer<z.ZodObject<typeof searchAgentsInput>>;
@ -328,6 +361,7 @@ type MutateAgentInput = z.infer<z.ZodObject<typeof mutateAgentInput>>;
type DiscoverAssetsInput = z.infer<z.ZodObject<typeof discoverAssetsInput>>;
type VerifyMcpServerInput = z.infer<z.ZodObject<typeof verifyMcpServerInput>>;
type UpdateIntegrationInput = z.infer<z.ZodObject<typeof updateIntegrationInput>>;
type CallAgentInput = z.infer<z.ZodObject<typeof callAgentInput>>;
type MutationResource = {
type: 'skill' | 'task' | 'customTool';
@ -353,6 +387,7 @@ export class McpAgentToolsService {
private readonly agentPublishService: AgentPublishService,
private readonly agentSkillsService: AgentSkillsService,
private readonly agentTaskService: AgentTaskService,
private readonly agentTestRunService: AgentTestRunService,
private readonly agentCustomToolsService: AgentCustomToolsService,
private readonly agentSecureRuntime: AgentSecureRuntime,
private readonly integrationPersistenceService: AgentIntegrationPersistenceService,
@ -384,6 +419,7 @@ export class McpAgentToolsService {
user: User,
allowedToolNames?: Set<string>,
): void {
const isCallAgentAvailable = allowedToolNames?.has(MCP_CALL_AGENT_TOOL_NAME) ?? true;
const registerIfAllowed = <Input extends z.ZodRawShape>(tool: ToolDefinition<Input>): void => {
if (allowedToolNames && !allowedToolNames.has(tool.name)) return;
registerTool(tool);
@ -393,7 +429,8 @@ export class McpAgentToolsService {
registerIfAllowed(this.getAgentTool(user));
registerIfAllowed(this.createAgentTool(user));
registerIfAllowed(this.mutateAgentTool(user));
registerIfAllowed(this.validateAgentTool(user));
registerIfAllowed(this.validateAgentTool(user, isCallAgentAvailable));
registerIfAllowed(this.callAgentTool(user));
registerIfAllowed(this.publishAgentTool(user));
registerIfAllowed(this.unpublishAgentTool(user));
registerIfAllowed(this.revertAgentTool(user));
@ -624,7 +661,10 @@ export class McpAgentToolsService {
};
}
private validateAgentTool(user: User): ToolDefinition<typeof agentIdentityShape> {
private validateAgentTool(
user: User,
isCallAgentAvailable: boolean,
): ToolDefinition<typeof agentIdentityShape> {
return {
name: 'validate_agent',
config: {
@ -643,15 +683,61 @@ export class McpAgentToolsService {
await this.run(user, 'validate_agent', { agentId }, async () => {
const agent = await this.resolveAgent(user, agentId);
await this.assertScope(user, agent.projectId, 'agent:read');
const validation = await this.validateAgent(user, agent);
const shouldSuggestCallAgent =
validation.valid &&
isCallAgentAvailable &&
(await userHasScopes(user, ['agent:execute'], false, { projectId: agent.projectId }));
return {
ok: true,
...(await this.validateAgent(user, agent)),
...validation,
url: this.getAgentUrl(agent.projectId, agentId),
...(shouldSuggestCallAgent
? {
nextStep: {
tool: MCP_CALL_AGENT_TOOL_NAME,
reason: 'Test the runnable draft before reporting it ready',
},
}
: {}),
};
}),
};
}
private callAgentTool(user: User): ToolDefinition<typeof callAgentInput> {
return {
name: MCP_CALL_AGENT_TOOL_NAME,
config: {
description:
'Test an Agent draft through built-in Preview chat. Start or continue a conversation with a message request, or resume one returned approval after the human decides. This uses real tools and credentials, so external side effects are possible.',
inputSchema: callAgentInput,
annotations: {
title: 'Call Agent',
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true,
},
},
handler: async (input: CallAgentInput, extra) => {
const abortSignal =
isRecord(extra) && extra.signal instanceof AbortSignal ? extra.signal : undefined;
return await this.run(
user,
MCP_CALL_AGENT_TOOL_NAME,
{ agentId: input.agentId },
async () => {
const agent = await this.resolveAgent(user, input.agentId);
await this.assertScope(user, agent.projectId, 'agent:execute');
return await this.callAgent(user, agent, input.request, abortSignal);
},
);
},
};
}
private publishAgentTool(user: User): ToolDefinition<typeof publishAgentInput> {
return {
name: 'publish_agent',
@ -1060,6 +1146,128 @@ export class McpAgentToolsService {
return `${this.urlService.getInstanceBaseUrl()}/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}`;
}
private async callAgent(
user: User,
agent: Agent,
request: CallAgentInput['request'],
abortSignal?: AbortSignal,
): Promise<Record<string, unknown>> {
const { id: agentId, projectId } = agent;
const previewUrl = `${this.getAgentUrl(projectId, agentId)}/preview`;
try {
let result: AgentTestRunResult;
if (request.type === 'message') {
result = await this.agentTestRunService.executeDraftRun({
agentId,
projectId,
message: request.message,
sessionId: request.sessionId,
credentialProvider: this.credentialProvider(user, projectId),
user,
source: 'mcp',
abortSignal,
});
} else {
result = await this.agentTestRunService.resumeDraftApproval({
agentId,
projectId,
continuation: request.continuation,
approved: request.approved,
user,
source: 'mcp',
abortSignal,
});
}
if (result.status === 'session_not_found') {
return {
ok: false,
status: 'error',
code: 'session_not_found',
message: 'Session not found.',
};
}
if (result.status === 'agent_misconfigured') {
return {
ok: false,
status: 'error',
code: 'agent_misconfigured',
message: "This agent isn't ready to run yet. Finish configuring it and try again.",
missing: result.missing,
};
}
if (result.status === 'completed') return { ok: true, ...result };
const approvals = collectStandardApprovals(result);
if (approvals) {
// TODO: Return approvals via MCP input_required: https://linear.app/n8n/issue/ADO-5754
return {
ok: true,
status: 'suspended',
response: result.response,
sessionId: result.sessionId,
...(result.executionId ? { executionId: result.executionId } : {}),
approvals,
};
}
const canOpenPreview = await userHasScopes(user, ['project:read', 'agent:read'], false, {
projectId,
});
const previewAccessNote = canOpenPreview
? undefined
: 'Your access permits running this agent but not opening Preview. Share the Preview URL with a project member who has project and agent read access.';
const cancelled = await this.agentTestRunService.cancelSuspendedRuns({
agentId,
suspensions: result.suspensions,
userId: user.id,
});
if (!cancelled) {
return {
ok: false,
status: 'error',
code: 'cancellation_failed',
message:
'This test needs approval, but its suspended run could not be cancelled. Open Preview before continuing this session.',
sessionId: result.sessionId,
previewUrl,
...(previewAccessNote ? { previewAccessNote } : {}),
};
}
return {
ok: true,
status: 'approval_required',
response: result.response,
sessionId: result.sessionId,
...(result.executionId ? { executionId: result.executionId } : {}),
suspensions: result.suspensions.map(({ runId, toolCallId, toolName }) => ({
runId,
toolCallId,
toolName,
})),
previewUrl,
...(previewAccessNote ? { previewAccessNote } : {}),
};
} catch (error) {
if (error instanceof InvalidAgentTestRunCheckpointError) {
return {
ok: false,
status: 'error',
code: error.code,
message: error.message,
};
}
return {
ok: false,
status: 'error',
code: 'execution_failed',
message: error instanceof Error ? error.message : 'Agent test run failed.',
};
}
}
/**
* Applies one mutation against the stale-checked `config`. Returns the
* post-mutation config when the mutation itself produced it; the caller

View File

@ -112,7 +112,7 @@ Error handling has two complementary layers. (1) Per-node: set onError ("continu
An n8n Agent is a first-class persisted Agent artifact, not an AI Agent node inside a workflow.
To build an Agent, first call get_agent_builder_reference (or read n8n://agents/reference), then discover the required assets, create the Agent, apply one mutation at a time using the latest configHash, and validate it. Agents are configured as JSON, not Workflow SDK code: the workflow-building steps do not apply to Agents, and ${MCP_GET_SDK_REFERENCE_TOOL.toolName} and ${MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName} are never needed for an Agent build — get_agent_builder_reference is the only required reference. A completed build is a saved draft, not a published Agent. Report that the draft is ready, include a clickable link using the \`url\` returned by validate_agent, and ask whether to publish it. Call publish_agent only if the user explicitly requested publication or confirms it after the build. Configuring a chat integration never publishes the Agent: a draft channel stays inactive until explicit publication, while a channel added to an already-published Agent connects immediately and therefore needs confirmation for that external connection. Use search_nodes with usage="agentTool" when configuring node-backed Agent tools.
To build an Agent, first call get_agent_builder_reference (or read n8n://agents/reference), then discover the required assets, create the Agent, apply one mutation at a time using the latest configHash, and validate it. Agents are configured as JSON, not Workflow SDK code: the workflow-building steps do not apply to Agents, and ${MCP_GET_SDK_REFERENCE_TOOL.toolName} and ${MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName} are never needed for an Agent build — get_agent_builder_reference is the only required reference. After validation succeeds, run one representative call_agent test when call_agent is available and authorized; otherwise, report the successfully validated draft ready without a test run. This tests Agent behavior, not channel integrations, and may cause side effects through real tools. Human approval is required for every returned approval; if the test exposes errors, report them and ask before fixing the draft. A completed build is a saved draft, not a published Agent. Report that the draft is ready, include a clickable link using the \`url\` returned by validate_agent, and ask whether to publish it. Call publish_agent only if the user explicitly requested publication or confirms it after the build. Configuring a chat integration never publishes the Agent: a draft channel stays inactive until explicit publication, while a channel added to an already-published Agent connects immediately and therefore needs confirmation for that external connection. Use search_nodes with usage="agentTool" when configuring node-backed Agent tools.
Agent conversations and runs are not workflow executions: get_workflow_execution and search_workflow_executions cover workflow executions only, including workflows an Agent invokes as tools.`;

View File

@ -1432,6 +1432,7 @@
"agentSessions.origin": "Origin",
"agentSessions.origin.agent": "Agent",
"agentSessions.origin.instanceAi": "AI Assistant",
"agentSessions.origin.mcp": "MCP",
"agentSessions.origin.subAgent": "Sub-agent",
"agentSessions.goToParentRun": "Go to parent run",
"agentSessions.viewTrace": "View session trace",
@ -3331,6 +3332,7 @@
"settings.mcp.oAuthClients.scope.execution.read": "Get execution details",
"settings.mcp.oAuthClients.scope.agent.read": "List and read agents",
"settings.mcp.oAuthClients.scope.agent.write": "Create and update agents",
"settings.mcp.oAuthClients.scope.agent.execute": "Run agents",
"settings.mcp.oAuthClients.scope.credential.read": "List credentials",
"settings.mcp.oAuthClients.scope.dataTable.read": "List data tables",
"settings.mcp.oAuthClients.scope.dataTable.write": "Create and update data tables",

View File

@ -24,6 +24,7 @@ vi.mock('@n8n/i18n', () => ({
'agentSessions.viewTrace': 'View session trace',
'agentSessions.origin.agent': 'Agent',
'agentSessions.origin.instanceAi': 'AI Assistant',
'agentSessions.origin.mcp': 'MCP',
'agentSessions.origin.subAgent': 'Sub-agent',
'agentSessions.origin.task': 'Task',
'agentSessions.empty': 'No agent sessions',
@ -235,6 +236,7 @@ describe('AgentSessionsListView', () => {
[{ source: 'slack' }, 'Slack'],
[{ source: 'telegram' }, 'Telegram'],
[{ source: 'instance-ai' }, 'AI Assistant'],
[{ source: 'mcp' }, 'MCP'],
[{ source: null }, 'Agent'],
[{ source: 'chat' }, 'Agent'],
[{ parentThreadId: 'parent-1', source: 'slack' }, 'Sub-agent'],

View File

@ -91,6 +91,7 @@ function originLabel(thread: AgentExecutionThread): string {
if (thread.taskId) return i18n.baseText('agentSessions.origin.task');
const source = thread.source?.trim();
if (source === 'instance-ai') return i18n.baseText('agentSessions.origin.instanceAi');
if (source === 'mcp') return i18n.baseText('agentSessions.origin.mcp');
if (
source &&
source !== 'chat' &&
@ -120,6 +121,8 @@ function originIcon(thread: AgentExecutionThread): string {
return 'linear';
case 'discord':
return 'discord';
case 'mcp':
return 'plug';
default:
return 'zap';
}