diff --git a/packages/@n8n/api-types/src/schemas/mcp.schema.ts b/packages/@n8n/api-types/src/schemas/mcp.schema.ts index 2265f1f2424..8f6fad7af6c 100644 --- a/packages/@n8n/api-types/src/schemas/mcp.schema.ts +++ b/packages/@n8n/api-types/src/schemas/mcp.schema.ts @@ -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 diff --git a/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts index 06aa93f55b1..e88bae81b2b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts @@ -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({ id: 'user-1' }); const credentialProvider = mock(); +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(); const agentValidationService = mock(); const agentExecutionOrchestratorService = mock(); + const n8nCheckpointStorage = mock(); 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, diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index ca73a49422d..9e85e2ef132 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -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({ diff --git a/packages/cli/src/modules/agents/agent-test-run.service.ts b/packages/cli/src/modules/agents/agent-test-run.service.ts index 8949e327ca8..cc303da487c 100644 --- a/packages/cli/src/modules/agents/agent-test-run.service.ts +++ b/packages/cli/src/modules/agents/agent-test-run.service.ts @@ -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; + 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; +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, +): 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 { + 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>; userId: string; }): Promise { - 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( diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 29cae4fe7e9..13345ebe506 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -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', diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index 968251299a8..3b7e53393a0 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -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()), 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 }; - handler: (input: Record) => Promise; + handler: (input: Record, extra: { signal: AbortSignal }) => Promise; }; 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, +) => ({ + runId, + toolCallId, + toolName, + input: args, + suspendPayload: { type: 'approval', toolName, args }, + resumeSchema: standardApprovalResumeSchema, +}); const agentEntity = (overrides: Record = {}): 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): Promise => { + const callTool = async ( + name: string, + input: Record, + signal: AbortSignal = new AbortController().signal, + ): Promise => { 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(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.', + }); }); }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts index f6b90fc11fc..521c821a71e 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/mcp/mcp-scopes.ts b/packages/cli/src/modules/mcp/mcp-scopes.ts index afa74e82001..ac32f3140cd 100644 --- a/packages/cli/src/modules/mcp/mcp-scopes.ts +++ b/packages/cli/src/modules/mcp/mcp-scopes.ts @@ -68,6 +68,7 @@ export const TOOLS_BY_SCOPE: Record = { '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 = new Set([ export const AGENT_TOOLS: ReadonlySet = new Set([ ...TOOLS_BY_SCOPE['agent:read'], ...TOOLS_BY_SCOPE['agent:write'], + ...TOOLS_BY_SCOPE['agent:execute'], ]); function isMcpScope(scope: string): scope is McpScope { diff --git a/packages/cli/src/modules/mcp/mcp.constants.ts b/packages/cli/src/modules/mcp/mcp.constants.ts index fa0048ea831..0666c916adc 100644 --- a/packages/cli/src/modules/mcp/mcp.constants.ts +++ b/packages/cli/src/modules/mcp/mcp.constants.ts @@ -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'; /** diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts b/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts index b909898027a..12deb4c313b 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts @@ -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 diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index ff07c1638f4..522adb412d5 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -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>; @@ -328,6 +361,7 @@ type MutateAgentInput = z.infer>; type DiscoverAssetsInput = z.infer>; type VerifyMcpServerInput = z.infer>; type UpdateIntegrationInput = z.infer>; +type CallAgentInput = z.infer>; 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, ): void { + const isCallAgentAvailable = allowedToolNames?.has(MCP_CALL_AGENT_TOOL_NAME) ?? true; const registerIfAllowed = (tool: ToolDefinition): 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 { + private validateAgentTool( + user: User, + isCallAgentAvailable: boolean, + ): ToolDefinition { 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 { + 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 { 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> { + 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 diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts index eb0c0da15f4..64370064692 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/mcp-instructions.ts @@ -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.`; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index a3e6f7df9e6..8a85c6b7b3d 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts index 4309a5cd085..eb8bfff00eb 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts @@ -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'], diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue index 54831d4d10e..0b40a3cbde6 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue @@ -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'; }