mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Expose agent test execution through MCP (#35744)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9d86e36ab9
commit
6f761ad917
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user