fix(ai-builder): Validate MCP tool names and schemas (no-changelog) (#29871)

This commit is contained in:
Albert Alises 2026-05-07 10:25:04 +02:00 committed by GitHub
parent 8dd6d12918
commit 273db4be75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1513 additions and 112 deletions

View File

@ -29,6 +29,7 @@ export {
InstanceAiConfirmRequestDto,
type InstanceAiConfirmRequest,
type InstanceAiConfirmRequestKind,
type InstanceAiResourceDecision,
} from './instance-ai/instance-ai-confirm-request.dto';
export { InstanceAiFeedbackRequestDto } from './instance-ai/instance-ai-feedback-request.dto';
export { InstanceAiRenameThreadRequestDto } from './instance-ai/instance-ai-rename-thread-request.dto';

View File

@ -63,7 +63,7 @@ describe('InstanceAiConfirmRequestDto', () => {
['domainAccessDeny', { kind: 'domainAccessDeny' }],
// confirmResourceDecision (store)
[
'resourceDecision with arbitrary decision token',
'resourceDecision with allowed decision token',
{ kind: 'resourceDecision', resourceDecision: 'allowForSession' },
],
// useSetupActions: handleApply
@ -130,6 +130,14 @@ describe('InstanceAiConfirmRequestDto', () => {
expect(result.success).toBe(false);
});
test('resourceDecision rejects persistent daemon-only decisions', () => {
const result = InstanceAiConfirmRequestDto.safeParse({
kind: 'resourceDecision',
resourceDecision: 'alwaysAllow',
});
expect(result.success).toBe(false);
});
test('setupWorkflowTestTrigger without testTriggerNode', () => {
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'setupWorkflowTestTrigger' });
expect(result.success).toBe(false);

View File

@ -1,6 +1,9 @@
import { z } from 'zod';
import { domainAccessActionSchema } from '../../schemas/instance-ai.schema';
import {
domainAccessActionSchema,
instanceGatewayResourceDecisionSchema,
} from '../../schemas/instance-ai.schema';
/**
* Plain approval/denial. Also carries optional `userInput` for:
@ -46,13 +49,10 @@ const domainAccessDenySchema = z.object({
kind: z.literal('domainAccessDeny'),
});
/** Gateway resource-access decision (inputType='resource-decision'). Approval is implied.
* `resourceDecision` is one of the opaque tokens listed in the request's `options[]` array
* (e.g. `'denyOnce'`, `'allowOnce'`, `'allowForSession'`) the daemon defines the vocabulary,
* so we keep this as a string rather than a fixed enum. */
/** Gateway resource-access decision (inputType='resource-decision'). Approval is implied. */
const resourceDecisionConfirmSchema = z.object({
kind: z.literal('resourceDecision'),
resourceDecision: z.string(),
resourceDecision: instanceGatewayResourceDecisionSchema,
});
/** Per-node credential map: `Record<nodeName, Record<credentialType, credentialId>>`. */
@ -90,3 +90,4 @@ export const InstanceAiConfirmRequestDto = z.discriminatedUnion('kind', [
export type InstanceAiConfirmRequest = z.infer<typeof InstanceAiConfirmRequestDto>;
export type InstanceAiConfirmRequestKind = InstanceAiConfirmRequest['kind'];
export type InstanceAiResourceDecision = z.infer<typeof instanceGatewayResourceDecisionSchema>;

View File

@ -324,7 +324,9 @@ export {
domainAccessActionSchema,
domainAccessMetaSchema,
credentialFlowSchema,
gatewayConfirmationRequiredWirePayloadSchema,
gatewayConfirmationRequiredPayloadSchema,
instanceGatewayResourceDecisionSchema,
GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
InstanceAiSendMessageRequest,
InstanceAiEvalExecutionRequest,
@ -401,7 +403,9 @@ export type {
DomainAccessAction,
DomainAccessMeta,
InstanceAiCredentialFlow,
GatewayConfirmationRequiredWirePayload,
GatewayConfirmationRequiredPayload,
InstanceGatewayResourceDecision,
ToolCategory,
InstanceAiWorkflowSetupNode,
PlannedTaskArg,

View File

@ -283,7 +283,14 @@ export type PlannedTaskArg = z.infer<typeof plannedTaskArgSchema>;
/** Protocol prefix used by the daemon to signal a resource-access confirmation is required. */
export const GATEWAY_CONFIRMATION_REQUIRED_PREFIX = 'GATEWAY_CONFIRMATION_REQUIRED::';
export const gatewayConfirmationRequiredPayloadSchema = z.object({
export const instanceGatewayResourceDecisionSchema = z.enum([
'denyOnce',
'allowOnce',
'allowForSession',
]);
export type InstanceGatewayResourceDecision = z.infer<typeof instanceGatewayResourceDecisionSchema>;
export const gatewayConfirmationRequiredWirePayloadSchema = z.object({
toolGroup: z.string(),
resource: z.string(),
description: z.string(),
@ -291,6 +298,15 @@ export const gatewayConfirmationRequiredPayloadSchema = z.object({
options: z.array(z.string()),
});
export type GatewayConfirmationRequiredWirePayload = z.infer<
typeof gatewayConfirmationRequiredWirePayloadSchema
>;
export const gatewayConfirmationRequiredPayloadSchema =
gatewayConfirmationRequiredWirePayloadSchema.extend({
options: z.array(instanceGatewayResourceDecisionSchema),
});
export type GatewayConfirmationRequiredPayload = z.infer<
typeof gatewayConfirmationRequiredPayloadSchema
>;

View File

@ -1,3 +1,5 @@
import type { ToolsInput } from '@mastra/core/agent';
jest.mock('@mastra/core/agent', () => ({
Agent: jest.fn().mockImplementation(function Agent(
this: { __registerMastra?: jest.Mock } & Record<string, unknown>,
@ -62,16 +64,28 @@ const { ToolSearchProcessor } =
const { Agent } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@mastra/core/agent') as { Agent: jest.Mock };
const { createToolsFromLocalMcpServer } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../../tools/filesystem/create-tools-from-mcp-server') as {
createToolsFromLocalMcpServer: jest.Mock;
};
function createMcpManagerStub() {
function createMcpManagerStub(regularTools: ToolsInput = {}, browserTools: ToolsInput = {}) {
return {
getRegularTools: jest.fn().mockResolvedValue({}),
getBrowserTools: jest.fn().mockResolvedValue({}),
getRegularTools: jest.fn().mockResolvedValue(regularTools),
getBrowserTools: jest.fn().mockResolvedValue(browserTools),
disconnect: jest.fn().mockResolvedValue(undefined),
};
}
describe('createInstanceAgent', () => {
beforeEach(() => {
Agent.mockClear();
ToolSearchProcessor.mockClear();
createToolsFromLocalMcpServer.mockReset();
createToolsFromLocalMcpServer.mockReturnValue({});
});
it('creates a fresh deferred tool processor for each run-scoped toolset', async () => {
const memoryConfig = {
storage: { id: 'memory-store' },
@ -111,7 +125,6 @@ describe('createInstanceAgent', () => {
});
it('does not attach a workspace to the orchestrator Agent', async () => {
Agent.mockClear();
const memoryConfig = { storage: { id: 'memory-store' } } as never;
const fakeWorkspace = { id: 'should-be-ignored' } as never;
@ -140,4 +153,48 @@ describe('createInstanceAgent', () => {
expect(firstCall).toBeDefined();
expect(firstCall[0]).not.toHaveProperty('workspace');
});
it('prefers local gateway tools over external MCP tools when names collide', async () => {
const memoryConfig = { storage: { id: 'memory-store' } } as never;
const localMcpServer = {
getToolsByCategory: jest.fn().mockReturnValue([]),
};
const localTools = {
shared_tool: { id: 'local-shared' },
} as unknown as ToolsInput;
const externalTools = {
shared_tool: { id: 'external-shared' },
github_workflows: { id: 'github-workflows' },
custom_plan: { id: 'custom-plan' },
} as unknown as ToolsInput;
const orchestrationContext: Record<string, unknown> = {
runId: 'local-priority',
browserMcpConfig: undefined,
};
createToolsFromLocalMcpServer.mockReturnValue(localTools);
await createInstanceAgent({
modelId: 'test-model',
context: {
runLabel: 'local-priority',
localGatewayStatus: undefined,
licenseHints: undefined,
localMcpServer,
},
orchestrationContext,
memoryConfig,
mcpManager: createMcpManagerStub(externalTools),
disableDeferredTools: true,
} as never);
const calls = Agent.mock.calls as Array<[Record<string, { tools?: ToolsInput }>]>;
const agentTools = calls[0]?.[0].tools as Record<string, { id: string }>;
const mcpContextTools = orchestrationContext.mcpTools as Record<string, { id: string }>;
expect(agentTools.shared_tool).toMatchObject({ id: 'local-shared' });
expect(agentTools.github_workflows).toMatchObject({ id: 'github-workflows' });
expect(agentTools.custom_plan).toMatchObject({ id: 'custom-plan' });
expect(mcpContextTools.shared_tool).toMatchObject({ id: 'local-shared' });
expect(mcpContextTools.github_workflows).toMatchObject({ id: 'github-workflows' });
});
});

View File

@ -0,0 +1,40 @@
import type { ToolsInput } from '@mastra/core/agent';
import { addSafeMcpTools, createClaimedToolNames } from '../mcp-tool-name-validation';
function makeTools(names: string[]): ToolsInput {
return Object.fromEntries(names.map((name) => [name, { id: name }])) as unknown as ToolsInput;
}
describe('MCP tool name validation', () => {
it('allows external tool names that contain native tool names as suffixes', () => {
const target: ToolsInput = {};
addSafeMcpTools(target, makeTools(['github_workflows', 'custom_plan']), {
source: 'external MCP',
claimedToolNames: createClaimedToolNames(['workflows', 'plan']),
});
expect(target.github_workflows).toBeDefined();
expect(target.custom_plan).toBeDefined();
});
it('still skips exact normalized name collisions with native tools', () => {
const target: ToolsInput = {};
const warn = jest.fn();
addSafeMcpTools(target, makeTools(['work-flows']), {
source: 'external MCP',
claimedToolNames: createClaimedToolNames(['workflows']),
warn,
});
expect(target['work-flows']).toBeUndefined();
expect(warn).toHaveBeenCalledWith(
expect.objectContaining({
source: 'external MCP',
toolName: 'work-flows',
}),
);
});
});

View File

@ -1,7 +1,11 @@
import type { ToolsInput } from '@mastra/core/agent';
import { z } from 'zod';
import { sanitizeMcpToolSchemas, sanitizeZodType } from '../sanitize-mcp-schemas';
import {
McpSchemaSanitizationError,
sanitizeMcpToolSchemas,
sanitizeZodType,
} from '../sanitize-mcp-schemas';
function makeTools(
schemas: Record<string, { input?: z.ZodTypeAny; output?: z.ZodTypeAny }>,
@ -17,6 +21,22 @@ function makeTools(
}
describe('sanitizeMcpToolSchemas', () => {
function makeDeepObject(depth: number): z.ZodTypeAny {
let schema: z.ZodTypeAny = z.string();
for (let i = 0; i < depth; i++) {
schema = z.object({ child: schema });
}
return schema;
}
function makeWideObject(width: number): z.ZodTypeAny {
const shape: z.ZodRawShape = {};
for (let i = 0; i < width; i++) {
shape[`field${i}`] = z.string();
}
return z.object(shape);
}
it('should return empty tools input unchanged', () => {
const tools = {} as ToolsInput;
@ -270,6 +290,170 @@ describe('sanitizeMcpToolSchemas', () => {
});
});
describe('depth bounding', () => {
it('should throw a typed error when a schema exceeds the maximum depth', () => {
expect(() => sanitizeZodType(makeDeepObject(4), false, { maxDepth: 2 })).toThrow(
McpSchemaSanitizationError,
);
});
it('should remove only the offending MCP tool when one schema is too deep', () => {
const onError = jest.fn();
const tools = makeTools({
validTool: { input: z.object({ name: z.string() }) },
deepTool: { input: makeDeepObject(4) },
});
const result = sanitizeMcpToolSchemas(tools, { maxDepth: 2, onError });
expect(Object.keys(result)).toEqual(['validTool']);
expect(onError).toHaveBeenCalledWith(expect.any(McpSchemaSanitizationError));
const onErrorCalls = onError.mock.calls as Array<[McpSchemaSanitizationError]>;
expect(onErrorCalls[0]?.[0].details.toolName).toBe('deepTool');
expect(onErrorCalls[0]?.[0].details.maxDepth).toBe(2);
});
it('should bound arrays, records, and unions', () => {
const tools = makeTools({
arrayTool: { input: z.array(makeDeepObject(3)) },
recordTool: { input: z.record(makeDeepObject(3)) },
unionTool: { input: z.union([makeDeepObject(3), z.null()]) },
});
const result = sanitizeMcpToolSchemas(tools, { maxDepth: 2 });
expect(Object.keys(result)).toEqual([]);
});
it('should bound lazy schemas', () => {
const onError = jest.fn();
const tools = makeTools({
lazyTool: { input: z.object({ payload: z.lazy(() => makeWideObject(4)) }) },
});
const result = sanitizeMcpToolSchemas(tools, {
maxObjectProperties: 2,
onError,
});
expect(Object.keys(result)).toEqual([]);
const onErrorCalls = onError.mock.calls as Array<[McpSchemaSanitizationError]>;
expect(onErrorCalls[0]?.[0].details.toolName).toBe('lazyTool');
expect(onErrorCalls[0]?.[0].details.limitType).toBe('objectProperties');
});
it('should remove tools containing unsupported tuple or intersection schemas', () => {
const onError = jest.fn();
const tools = makeTools({
tupleTool: { input: z.object({ pair: z.tuple([z.string(), z.null()]) }) },
intersectionTool: {
input: z.object({
payload: z.intersection(z.object({ name: z.string() }), z.object({ id: z.string() })),
}),
},
});
const result = sanitizeMcpToolSchemas(tools, { onError });
expect(Object.keys(result)).toEqual([]);
const onErrorCalls = onError.mock.calls as Array<[McpSchemaSanitizationError]>;
expect(onErrorCalls.map(([error]) => error.details)).toEqual(
expect.arrayContaining([
expect.objectContaining({
toolName: 'tupleTool',
limitType: 'unsupportedType',
zodType: 'ZodTuple',
}),
expect.objectContaining({
toolName: 'intersectionTool',
limitType: 'unsupportedType',
zodType: 'ZodIntersection',
}),
]),
);
});
it('should remove tools containing unsupported wrapper types', () => {
const onError = jest.fn();
const tools = makeTools({
mapTool: { input: z.object({ values: z.map(z.string(), z.string()) }) },
});
const result = sanitizeMcpToolSchemas(tools, { onError });
expect(Object.keys(result)).toEqual([]);
const onErrorCalls = onError.mock.calls as Array<[McpSchemaSanitizationError]>;
expect(onErrorCalls[0]?.[0].details.toolName).toBe('mapTool');
expect(onErrorCalls[0]?.[0].details.limitType).toBe('unsupportedType');
expect(onErrorCalls[0]?.[0].details.zodType).toBe('ZodMap');
});
it('should remove a shallow MCP tool with too many object properties', () => {
const onError = jest.fn();
const tools = makeTools({
wideTool: { input: makeWideObject(4) },
});
const result = sanitizeMcpToolSchemas(tools, {
maxObjectProperties: 2,
onError,
});
expect(Object.keys(result)).toEqual([]);
const onErrorCalls = onError.mock.calls as Array<[McpSchemaSanitizationError]>;
expect(onErrorCalls[0]?.[0].details.toolName).toBe('wideTool');
expect(onErrorCalls[0]?.[0].details.limitType).toBe('objectProperties');
expect(onErrorCalls[0]?.[0].details.limit).toBe(2);
expect(onErrorCalls[0]?.[0].details.count).toBe(4);
});
it('should remove a shallow MCP tool with too many union options', () => {
const onError = jest.fn();
const tools = makeTools({
unionTool: {
input: z.object({
value: z.union([z.literal('a'), z.literal('b'), z.literal('c')]),
}),
},
});
const result = sanitizeMcpToolSchemas(tools, {
maxUnionOptions: 2,
onError,
});
expect(Object.keys(result)).toEqual([]);
const onErrorCalls = onError.mock.calls as Array<[McpSchemaSanitizationError]>;
expect(onErrorCalls[0]?.[0].details.toolName).toBe('unionTool');
expect(onErrorCalls[0]?.[0].details.limitType).toBe('unionOptions');
expect(onErrorCalls[0]?.[0].details.limit).toBe(2);
expect(onErrorCalls[0]?.[0].details.count).toBe(3);
});
it('should remove an MCP tool that exceeds the total schema node budget', () => {
const onError = jest.fn();
const tools = makeTools({
nodeBudgetTool: {
input: z.object({
first: z.string(),
second: z.string(),
}),
},
});
const result = sanitizeMcpToolSchemas(tools, {
maxNodes: 2,
onError,
});
expect(Object.keys(result)).toEqual([]);
const onErrorCalls = onError.mock.calls as Array<[McpSchemaSanitizationError]>;
expect(onErrorCalls[0]?.[0].details.toolName).toBe('nodeBudgetTool');
expect(onErrorCalls[0]?.[0].details.limitType).toBe('nodes');
expect(onErrorCalls[0]?.[0].details.limit).toBe(2);
});
});
describe('strict mode', () => {
it('should throw on conflicting field descriptions in discriminated unions', () => {
const union = z.discriminatedUnion('action', [

View File

@ -10,6 +10,11 @@ import { getSystemPrompt } from './system-prompt';
import { createToolsFromLocalMcpServer } from '../tools/filesystem/create-tools-from-mcp-server';
import { buildAgentTraceInputs, mergeTraceRunInputs } from '../tracing/langsmith-tracing';
import type { CreateInstanceAgentOptions } from '../types';
import {
addSafeMcpTools,
createClaimedToolNames,
type McpToolNameValidationError,
} from './mcp-tool-name-validation';
let cachedMastra: Mastra | null = null;
let cachedMastraStorageKey = '';
@ -60,8 +65,14 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions):
// Load MCP tools (cached by config-hash inside the manager — only spawns
// processes / opens connections on first call or config change).
const mcpTools = await mcpManager.getRegularTools(mcpServers);
const browserMcpTools = await mcpManager.getBrowserTools(orchestrationContext?.browserMcpConfig);
const mcpTools = await mcpManager.getRegularTools(mcpServers, context.logger);
const browserMcpTools = await mcpManager.getBrowserTools(
orchestrationContext?.browserMcpConfig,
context.logger,
);
const rawLocalMcpTools = context.localMcpServer
? createToolsFromLocalMcpServer(context.localMcpServer, context.logger)
: {};
// Browser tool names — used to exclude them from the orchestrator's direct toolset.
// Browser tools are only accessible via browser-credential-setup (sub-agent) to prevent
@ -71,54 +82,73 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions):
...(context.localMcpServer?.getToolsByCategory('browser').map((t) => t.name) ?? []),
]);
// Store ALL MCP tools (external + browser) on orchestrationContext for sub-agents
// (browser-credential-setup, delegate). NOT given to the orchestrator directly.
// Store ALL MCP tools (external + browser + local gateway) on orchestrationContext for
// sub-agents (browser-credential-setup, delegate). NOT given to the orchestrator directly.
const allMcpTools: ToolsInput = {};
const domainToolNames = new Set(Object.keys(domainTools));
for (const [name, tool] of Object.entries({ ...mcpTools, ...browserMcpTools })) {
if (!domainToolNames.has(name)) {
allMcpTools[name] = tool;
}
}
if (orchestrationContext && Object.keys(allMcpTools).length > 0) {
orchestrationContext.mcpTools = allMcpTools;
}
const warnSkippedMcpTool = (error: McpToolNameValidationError) => {
context.logger?.warn('Skipped MCP tool with unsafe name', {
toolName: error.toolName,
source: error.source,
reason: error.message,
});
};
// Build orchestration tools (plan, delegate) — orchestrator-only
// Must happen after mcpTools are set on orchestrationContext
const orchestrationTools = orchestrationContext
? createOrchestrationTools(orchestrationContext)
: {};
// Prevent MCP tools from shadowing domain or orchestration tools.
// A malicious/misconfigured MCP server could register a tool named "run-workflow"
// which would silently replace the real domain tool via object spread.
// Keep MCP tools from shadowing domain or orchestration tools during object composition.
const reservedToolNames = new Set([
...Object.keys(domainTools),
...Object.keys(orchestrationTools),
]);
const safeMcpTools: ToolsInput = {};
for (const [name, tool] of Object.entries(mcpTools)) {
if (reservedToolNames.has(name)) continue;
safeMcpTools[name] = tool;
const mcpContextToolNames = createClaimedToolNames(reservedToolNames);
addSafeMcpTools(allMcpTools, rawLocalMcpTools, {
source: 'local gateway MCP',
claimedToolNames: mcpContextToolNames,
warn: warnSkippedMcpTool,
});
addSafeMcpTools(allMcpTools, mcpTools, {
source: 'external MCP',
claimedToolNames: mcpContextToolNames,
warn: warnSkippedMcpTool,
});
addSafeMcpTools(allMcpTools, browserMcpTools, {
source: 'browser MCP',
claimedToolNames: mcpContextToolNames,
warn: warnSkippedMcpTool,
});
const orchestratorLocalMcpTools = Object.fromEntries(
Object.entries(rawLocalMcpTools).filter(([name]) => !browserToolNames.has(name)),
);
if (orchestrationContext && Object.keys(allMcpTools).length > 0) {
orchestrationContext.mcpTools = allMcpTools;
}
const claimedOrchestratorToolNames = createClaimedToolNames(reservedToolNames);
const safeLocalMcpTools: ToolsInput = {};
addSafeMcpTools(safeLocalMcpTools, orchestratorLocalMcpTools, {
source: 'local gateway MCP',
claimedToolNames: claimedOrchestratorToolNames,
warn: warnSkippedMcpTool,
});
const safeMcpTools: ToolsInput = {};
addSafeMcpTools(safeMcpTools, mcpTools, {
source: 'external MCP',
claimedToolNames: claimedOrchestratorToolNames,
warn: warnSkippedMcpTool,
});
// ── Tool search: split tools into always-loaded core vs deferred ────────
// Anthropic guidance: "Keep your 3-5 most-used tools always loaded, defer the rest."
// Tool selection accuracy degrades past 10+ tools; tool search improves it significantly.
const localMcpTools = context.localMcpServer
? Object.fromEntries(
Object.entries(createToolsFromLocalMcpServer(context.localMcpServer)).filter(
([name]) => !browserToolNames.has(name),
),
)
: {};
const allOrchestratorTools: ToolsInput = {
...orchestratorDomainTools,
...orchestrationTools,
...safeLocalMcpTools, // gateway tools — browser tools excluded via browserToolNames
...safeMcpTools, // external MCP only — browser tools excluded
...localMcpTools, // gateway tools — browser tools excluded via browserToolNames
};
const tracedOrchestratorTools =
orchestrationContext?.tracing?.wrapTools(allOrchestratorTools, {

View File

@ -0,0 +1,82 @@
import type { ToolsInput } from '@mastra/core/agent';
import { isSafeObjectKey } from '@n8n/api-types';
export class McpToolNameValidationError extends Error {
constructor(
message: string,
readonly toolName: string,
readonly source: string,
) {
super(message);
this.name = 'McpToolNameValidationError';
}
}
const MCP_TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
export function isSafeMcpIdentifierName(name: string): boolean {
const normalizedName = name.normalize('NFKC');
return (
normalizedName === name &&
MCP_TOOL_NAME_PATTERN.test(name) &&
isSafeObjectKey(normalizedName.toLowerCase())
);
}
export function normalizeMcpToolName(name: string): string {
return name
.normalize('NFKC')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
export function validateMcpToolName(name: string, source: string): string {
if (!isSafeMcpIdentifierName(name)) {
throw new McpToolNameValidationError(
`MCP tool "${name}" from ${source} has an invalid name`,
name,
source,
);
}
return normalizeMcpToolName(name);
}
export function createClaimedToolNames(names: Iterable<string>): Map<string, string> {
const claimed = new Map<string, string>();
for (const name of names) {
claimed.set(normalizeMcpToolName(name), name);
}
return claimed;
}
export function addSafeMcpTools(
target: ToolsInput,
sourceTools: ToolsInput,
options: {
source: string;
claimedToolNames: Map<string, string>;
warn?: (error: McpToolNameValidationError) => void;
},
): void {
for (const [name, tool] of Object.entries(sourceTools)) {
try {
const normalizedName = validateMcpToolName(name, options.source);
const claimedBy = options.claimedToolNames.get(normalizedName);
if (claimedBy) {
throw new McpToolNameValidationError(
`MCP tool "${name}" from ${options.source} conflicts with "${claimedBy}"`,
name,
options.source,
);
}
options.claimedToolNames.set(normalizedName, name);
target[name] = tool;
} catch (error) {
if (error instanceof McpToolNameValidationError) {
options.warn?.(error);
continue;
}
throw error;
}
}
}

View File

@ -15,6 +15,194 @@
import type { ToolsInput } from '@mastra/core/agent';
import { z } from 'zod';
export const MCP_SCHEMA_MAX_DEPTH = 32;
export const MCP_SCHEMA_MAX_NODES = 1_000;
export const MCP_SCHEMA_MAX_OBJECT_PROPERTIES = 250;
export const MCP_SCHEMA_MAX_UNION_OPTIONS = 100;
type McpSchemaLimitType =
| 'depth'
| 'nodes'
| 'objectProperties'
| 'unionOptions'
| 'unsupportedType';
export class McpSchemaSanitizationError extends Error {
constructor(
message: string,
readonly details: {
toolName?: string;
path: string;
depth: number;
maxDepth: number;
limit?: number;
limitType?: McpSchemaLimitType;
count?: number;
zodType?: string;
},
) {
super(message);
this.name = 'McpSchemaSanitizationError';
}
}
interface SanitizeBudget {
nodes: number;
}
interface SanitizeContext {
strict: boolean;
toolName?: string;
path: string;
depth: number;
maxDepth: number;
maxNodes: number;
maxObjectProperties: number;
maxUnionOptions: number;
budget: SanitizeBudget;
}
interface SanitizeZodTypeOptions {
maxDepth?: number;
maxNodes?: number;
maxObjectProperties?: number;
maxUnionOptions?: number;
toolName?: string;
path?: string;
budget?: SanitizeBudget;
}
interface ValidateJsonSchemaOptions {
maxDepth?: number;
maxNodes?: number;
maxObjectProperties?: number;
maxUnionOptions?: number;
toolName?: string;
}
interface JsonSchemaValidationContext {
toolName?: string;
maxDepth: number;
maxNodes: number;
maxObjectProperties: number;
maxUnionOptions: number;
budget: SanitizeBudget;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function throwJsonSchemaLimitError(
context: JsonSchemaValidationContext,
path: string,
depth: number,
message: string,
limitType: McpSchemaLimitType,
limit: number,
count?: number,
): never {
throw new McpSchemaSanitizationError(message, {
toolName: context.toolName,
path,
depth,
maxDepth: context.maxDepth,
limit,
limitType,
count,
});
}
function validateJsonSchemaNode(
value: unknown,
path: string,
depth: number,
context: JsonSchemaValidationContext,
): void {
if (depth > context.maxDepth) {
throwJsonSchemaLimitError(
context,
path,
depth,
`MCP schema exceeds maximum depth of ${context.maxDepth}`,
'depth',
context.maxDepth,
depth,
);
}
context.budget.nodes++;
if (context.budget.nodes > context.maxNodes) {
throwJsonSchemaLimitError(
context,
path,
depth,
`MCP schema exceeds maximum node count of ${context.maxNodes}`,
'nodes',
context.maxNodes,
context.budget.nodes,
);
}
if (Array.isArray(value)) {
for (const [index, item] of value.entries()) {
validateJsonSchemaNode(item, `${path}[${index}]`, depth + 1, context);
}
return;
}
if (!isRecord(value)) return;
const properties = value.properties;
if (isRecord(properties)) {
const propertyCount = Object.keys(properties).length;
if (propertyCount > context.maxObjectProperties) {
throwJsonSchemaLimitError(
context,
`${path}.properties`,
depth + 1,
`MCP schema object exceeds maximum property count of ${context.maxObjectProperties}`,
'objectProperties',
context.maxObjectProperties,
propertyCount,
);
}
}
for (const unionKey of ['anyOf', 'oneOf', 'allOf']) {
const unionOptions = value[unionKey];
if (Array.isArray(unionOptions) && unionOptions.length > context.maxUnionOptions) {
throwJsonSchemaLimitError(
context,
`${path}.${unionKey}`,
depth + 1,
`MCP schema union exceeds maximum option count of ${context.maxUnionOptions}`,
'unionOptions',
context.maxUnionOptions,
unionOptions.length,
);
}
}
for (const [key, child] of Object.entries(value)) {
validateJsonSchemaNode(child, `${path}.${key}`, depth + 1, context);
}
}
export function assertMcpJsonSchemaWithinLimits(
schema: unknown,
options: ValidateJsonSchemaOptions = {},
): void {
validateJsonSchemaNode(schema, '$.inputSchema', 0, {
toolName: options.toolName,
maxDepth: options.maxDepth ?? MCP_SCHEMA_MAX_DEPTH,
maxNodes: options.maxNodes ?? MCP_SCHEMA_MAX_NODES,
maxObjectProperties: options.maxObjectProperties ?? MCP_SCHEMA_MAX_OBJECT_PROPERTIES,
maxUnionOptions: options.maxUnionOptions ?? MCP_SCHEMA_MAX_UNION_OPTIONS,
budget: { nodes: 0 },
});
}
/**
* Recursively walk a Zod schema tree and replace Anthropic-incompatible types.
*
@ -23,7 +211,101 @@ import { z } from 'zod';
* mismatched descriptions at construction time rather than silently degrading
* the schema the model sees.
*/
export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodTypeAny {
export function sanitizeZodType(
schema: z.ZodTypeAny,
strict = false,
options: SanitizeZodTypeOptions = {},
): z.ZodTypeAny {
return sanitizeZodTypeInner(schema, {
strict,
toolName: options.toolName,
path: options.path ?? '$',
depth: 0,
maxDepth: options.maxDepth ?? MCP_SCHEMA_MAX_DEPTH,
maxNodes: options.maxNodes ?? MCP_SCHEMA_MAX_NODES,
maxObjectProperties: options.maxObjectProperties ?? MCP_SCHEMA_MAX_OBJECT_PROPERTIES,
maxUnionOptions: options.maxUnionOptions ?? MCP_SCHEMA_MAX_UNION_OPTIONS,
budget: options.budget ?? { nodes: 0 },
});
}
function createLimitError(
context: SanitizeContext,
message: string,
limitType: McpSchemaLimitType,
limit: number,
count?: number,
): McpSchemaSanitizationError {
return new McpSchemaSanitizationError(message, {
toolName: context.toolName,
path: context.path,
depth: context.depth,
maxDepth: context.maxDepth,
limit,
limitType,
count,
});
}
function createUnsupportedTypeError(
context: SanitizeContext,
schema: z.ZodTypeAny,
): McpSchemaSanitizationError {
const definition = schema._def as { typeName?: unknown };
const zodType =
typeof definition.typeName === 'string' ? definition.typeName : schema.constructor.name;
return new McpSchemaSanitizationError(`MCP schema contains unsupported Zod type ${zodType}`, {
toolName: context.toolName,
path: context.path,
depth: context.depth,
maxDepth: context.maxDepth,
limitType: 'unsupportedType',
zodType,
});
}
function isSupportedLeafSchema(schema: z.ZodTypeAny): boolean {
return (
schema instanceof z.ZodString ||
schema instanceof z.ZodNumber ||
schema instanceof z.ZodBoolean ||
schema instanceof z.ZodDate ||
schema instanceof z.ZodAny ||
schema instanceof z.ZodUnknown ||
schema instanceof z.ZodLiteral ||
schema instanceof z.ZodEnum ||
schema instanceof z.ZodNativeEnum
);
}
function sanitizeZodTypeInner(schema: z.ZodTypeAny, context: SanitizeContext): z.ZodTypeAny {
if (context.depth > context.maxDepth) {
throw createLimitError(
context,
`MCP schema exceeds maximum depth of ${context.maxDepth}`,
'depth',
context.maxDepth,
context.depth,
);
}
context.budget.nodes++;
if (context.budget.nodes > context.maxNodes) {
throw createLimitError(
context,
`MCP schema exceeds maximum node count of ${context.maxNodes}`,
'nodes',
context.maxNodes,
context.budget.nodes,
);
}
const sanitizeChild = (child: z.ZodTypeAny, path: string): z.ZodTypeAny =>
sanitizeZodTypeInner(child, {
...context,
path,
depth: context.depth + 1,
});
// ZodNull → replace with optional undefined (shouldn't appear standalone, but handle it)
if (schema instanceof z.ZodNull) {
return z.string().optional();
@ -31,7 +313,10 @@ export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodType
// ZodNullable<T> → T.optional()
if (schema instanceof z.ZodNullable) {
return sanitizeZodType((schema as z.ZodNullable<z.ZodTypeAny>).unwrap(), strict).optional();
return sanitizeChild(
(schema as z.ZodNullable<z.ZodTypeAny>).unwrap(),
`${context.path}?`,
).optional();
}
// ZodDiscriminatedUnion — flatten to a single z.object
@ -42,6 +327,15 @@ export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodType
const disc = schema as z.ZodDiscriminatedUnion<string, Array<z.ZodObject<z.ZodRawShape>>>;
const discriminator = disc.discriminator;
const variants = [...disc.options.values()] as Array<z.ZodObject<z.ZodRawShape>>;
if (variants.length > context.maxUnionOptions) {
throw createLimitError(
context,
`MCP schema discriminated union exceeds maximum option count of ${context.maxUnionOptions}`,
'unionOptions',
context.maxUnionOptions,
variants.length,
);
}
// Phase 1: Collect metadata from all variants
const actionMeta: Array<{ value: string; description?: string }> = [];
@ -70,6 +364,16 @@ export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodType
});
}
}
const mergedPropertyCount = fieldMeta.size + (actionMeta.length > 0 ? 1 : 0);
if (mergedPropertyCount > context.maxObjectProperties) {
throw createLimitError(
context,
`MCP schema object exceeds maximum property count of ${context.maxObjectProperties}`,
'objectProperties',
context.maxObjectProperties,
mergedPropertyCount,
);
}
// Phase 2: Build the merged shape
const mergedShape: z.ZodRawShape = {};
@ -87,12 +391,15 @@ export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodType
// Build each field with properly merged descriptions
for (const [fieldName, entries] of fieldMeta) {
const sanitizedField = sanitizeZodType(entries[0].type, strict).optional();
const sanitizedField = sanitizeChild(
entries[0].type,
`${context.path}.${fieldName}`,
).optional();
// Detect enum value conflicts across variants.
// Only the first variant's type is used (entries[0].type), so differing
// enum values in other variants would be silently lost.
if (strict && entries.length > 1) {
if (context.strict && entries.length > 1) {
const unwrapOptional = (t: z.ZodTypeAny): z.ZodTypeAny =>
t instanceof z.ZodOptional ? unwrapOptional(t.unwrap() as z.ZodTypeAny) : t;
@ -128,7 +435,7 @@ export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodType
const uniqueDescs = new Set(withDesc.map((d) => d.description));
if (uniqueDescs.size > 1) {
if (strict) {
if (context.strict) {
const conflictDetails = withDesc
.map((d) => ` Action "${d.action}": "${d.description}"`)
.join('\n');
@ -161,9 +468,20 @@ export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodType
if (schema instanceof z.ZodUnion) {
const options = (schema as z.ZodUnion<[z.ZodTypeAny, ...z.ZodTypeAny[]]>)
.options as z.ZodTypeAny[];
if (options.length > context.maxUnionOptions) {
throw createLimitError(
context,
`MCP schema union exceeds maximum option count of ${context.maxUnionOptions}`,
'unionOptions',
context.maxUnionOptions,
options.length,
);
}
const nonNull = options.filter((o) => !(o instanceof z.ZodNull));
const hadNull = nonNull.length < options.length;
const sanitized = nonNull.map((o) => sanitizeZodType(o, strict));
const sanitized = nonNull.map((o, index) =>
sanitizeChild(o, `${context.path}.union[${index}]`),
);
if (sanitized.length === 0) {
// All options were null — degenerate case
@ -179,27 +497,47 @@ export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodType
// ZodObject — recurse into shape
if (schema instanceof z.ZodObject) {
const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
const entries = Object.entries(shape);
if (entries.length > context.maxObjectProperties) {
throw createLimitError(
context,
`MCP schema object exceeds maximum property count of ${context.maxObjectProperties}`,
'objectProperties',
context.maxObjectProperties,
entries.length,
);
}
const newShape: z.ZodRawShape = {};
for (const [key, value] of Object.entries(shape)) {
newShape[key] = sanitizeZodType(value, strict);
for (const [key, value] of entries) {
newShape[key] = sanitizeChild(value, `${context.path}.${key}`);
}
return z.object(newShape);
}
// ZodLazy - resolve during sanitization so limits and null-stripping still apply
if (schema instanceof z.ZodLazy) {
return sanitizeChild((schema as z.ZodLazy<z.ZodTypeAny>).schema, `${context.path}.lazy`);
}
// ZodOptional — recurse into inner
if (schema instanceof z.ZodOptional) {
return sanitizeZodType((schema as z.ZodOptional<z.ZodTypeAny>).unwrap(), strict).optional();
return sanitizeChild(
(schema as z.ZodOptional<z.ZodTypeAny>).unwrap(),
`${context.path}?`,
).optional();
}
// ZodArray — recurse into element
if (schema instanceof z.ZodArray) {
return z.array(sanitizeZodType((schema as z.ZodArray<z.ZodTypeAny>).element, strict));
return z.array(
sanitizeChild((schema as z.ZodArray<z.ZodTypeAny>).element, `${context.path}[]`),
);
}
// ZodDefault — recurse into inner
if (schema instanceof z.ZodDefault) {
const inner = (schema as z.ZodDefault<z.ZodTypeAny>)._def.innerType;
return sanitizeZodType(inner, strict).default(
return sanitizeChild(inner, `${context.path}.default`).default(
(schema as z.ZodDefault<z.ZodTypeAny>)._def.defaultValue(),
);
}
@ -207,12 +545,74 @@ export function sanitizeZodType(schema: z.ZodTypeAny, strict = false): z.ZodType
// ZodRecord — recurse into value type
if (schema instanceof z.ZodRecord) {
return z.record(
sanitizeZodType((schema as z.ZodRecord<z.ZodString, z.ZodTypeAny>).valueSchema, strict),
sanitizeChild(
(schema as z.ZodRecord<z.ZodString, z.ZodTypeAny>).valueSchema,
`${context.path}.*`,
),
);
}
// Leaf types (string, number, boolean, enum, literal, etc.) — pass through
return schema;
// ZodEffects - recurse into the source type. Effects are runtime behavior,
// but the provider only needs a safe JSON-compatible schema.
if (schema instanceof z.ZodEffects) {
return sanitizeChild(
(schema as z.ZodEffects<z.ZodTypeAny>).innerType(),
`${context.path}.effect`,
);
}
// ZodPipeline - recurse into both schemas so nested unsupported types cannot hide
if (schema instanceof z.ZodPipeline) {
const pipeline = schema as z.ZodPipeline<z.ZodTypeAny, z.ZodTypeAny>;
return z.pipeline(
sanitizeChild(pipeline._def.in, `${context.path}.pipeline.in`),
sanitizeChild(pipeline._def.out, `${context.path}.pipeline.out`),
);
}
// ZodReadonly / ZodBranded / ZodCatch - recurse into the inner type. The wrappers
// do not add useful provider-schema information, so preserving the safe inner
// schema is preferable to letting nested unsupported types slip through.
if (schema instanceof z.ZodReadonly) {
return sanitizeChild(
(schema as z.ZodReadonly<z.ZodTypeAny>).unwrap(),
`${context.path}.readonly`,
);
}
if (schema instanceof z.ZodBranded) {
return sanitizeChild(
(schema as z.ZodBranded<z.ZodTypeAny, string>).unwrap(),
`${context.path}.brand`,
);
}
if (schema instanceof z.ZodCatch) {
return sanitizeChild(
(schema as z.ZodCatch<z.ZodTypeAny>).removeCatch(),
`${context.path}.catch`,
);
}
if (
schema instanceof z.ZodMap ||
schema instanceof z.ZodSet ||
schema instanceof z.ZodPromise ||
schema instanceof z.ZodFunction ||
schema instanceof z.ZodIntersection ||
schema instanceof z.ZodTuple ||
schema instanceof z.ZodNaN ||
schema instanceof z.ZodBigInt ||
schema instanceof z.ZodUndefined ||
schema instanceof z.ZodNever ||
schema instanceof z.ZodVoid ||
schema instanceof z.ZodSymbol
) {
throw createUnsupportedTypeError(context, schema);
}
// Leaf types (string, number, boolean, enum, literal, etc.) - pass through.
if (isSupportedLeafSchema(schema)) return schema;
throw createUnsupportedTypeError(context, schema);
}
/**
@ -259,14 +659,51 @@ export function sanitizeInputSchema<T extends z.ZodTypeAny>(schema: T): T {
* action context (e.g. 'For "create": ... For "delete": ...') rather than
* throwing.
*/
export function sanitizeMcpToolSchemas(tools: ToolsInput): ToolsInput {
for (const tool of Object.values(tools)) {
export function sanitizeMcpToolSchemas(
tools: ToolsInput,
options: {
maxDepth?: number;
maxNodes?: number;
maxObjectProperties?: number;
maxUnionOptions?: number;
onError?: (error: McpSchemaSanitizationError) => void;
} = {},
): ToolsInput {
for (const [name, tool] of Object.entries(tools)) {
const t = tool as { inputSchema?: z.ZodTypeAny; outputSchema?: z.ZodTypeAny };
if (t.inputSchema) {
t.inputSchema = ensureTopLevelObject(sanitizeZodType(t.inputSchema));
}
if (t.outputSchema) {
t.outputSchema = sanitizeZodType(t.outputSchema);
const budget = { nodes: 0 };
try {
if (t.inputSchema) {
t.inputSchema = ensureTopLevelObject(
sanitizeZodType(t.inputSchema, false, {
maxDepth: options.maxDepth,
maxNodes: options.maxNodes,
maxObjectProperties: options.maxObjectProperties,
maxUnionOptions: options.maxUnionOptions,
toolName: name,
path: '$.inputSchema',
budget,
}),
);
}
if (t.outputSchema) {
t.outputSchema = sanitizeZodType(t.outputSchema, false, {
maxDepth: options.maxDepth,
maxNodes: options.maxNodes,
maxObjectProperties: options.maxObjectProperties,
maxUnionOptions: options.maxUnionOptions,
toolName: name,
path: '$.outputSchema',
budget,
});
}
} catch (error) {
if (error instanceof McpSchemaSanitizationError) {
delete (tools as Record<string, unknown>)[name];
options.onError?.(error);
continue;
}
throw error;
}
}

View File

@ -17,6 +17,29 @@ import { McpClientManager } from '../mcp-client-manager';
const { MCPClient: mockedMcpClient } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@mastra/mcp') as { MCPClient: jest.Mock };
const { sanitizeMcpToolSchemas: mockedSanitizeMcpToolSchemas } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../../agent/sanitize-mcp-schemas') as {
sanitizeMcpToolSchemas: jest.Mock;
};
interface LoggerMock {
warn: jest.Mock;
}
interface SanitizeOptions {
onError?: (error: {
message: string;
details: {
toolName?: string;
path: string;
depth: number;
maxDepth: number;
limitType?: string;
limit?: number;
};
}) => void;
}
function createValidatorMock(): jest.Mocked<SsrfUrlValidator> {
return {
@ -83,6 +106,93 @@ describe('McpClientManager', () => {
});
});
describe('server and schema filtering', () => {
it('skips external MCP servers with unsafe names', async () => {
const logger: LoggerMock = { warn: jest.fn() };
const manager = new McpClientManager();
await manager.getRegularTools(
[
{ name: 'bad name', url: 'https://bad.example.com/mcp' },
{ name: 'safe_server', url: 'https://safe.example.com/mcp' },
],
logger as never,
);
expect(mockedMcpClient).toHaveBeenCalledTimes(1);
const mcpClientCalls = mockedMcpClient.mock.calls as Array<
[{ servers: Record<string, unknown> }]
>;
const [mcpClientConfig] = mcpClientCalls[0];
expect(mcpClientConfig.servers).not.toHaveProperty('bad name');
expect(mcpClientConfig.servers).toHaveProperty('safe_server');
expect(logger.warn).toHaveBeenCalledWith(
'Skipped MCP server with unsafe name',
expect.objectContaining({
serverName: 'bad name',
source: 'external MCP',
}),
);
});
it('skips browser MCP configs with unsafe names', async () => {
const logger: LoggerMock = { warn: jest.fn() };
const manager = new McpClientManager();
await expect(
manager.getBrowserTools(
{ name: 'bad name', url: 'https://browser.example.com/mcp' },
logger as never,
),
).resolves.toEqual({});
expect(mockedMcpClient).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Skipped MCP server with unsafe name',
expect.objectContaining({
serverName: 'bad name',
source: 'browser MCP',
}),
);
});
it('logs tools skipped during schema sanitization', async () => {
const logger: LoggerMock = { warn: jest.fn() };
mockedSanitizeMcpToolSchemas.mockImplementationOnce(
(_tools: Record<string, unknown>, options?: SanitizeOptions) => {
options?.onError?.({
message: 'MCP schema exceeds maximum depth of 32',
details: {
toolName: 'deep_tool',
path: '$.input',
depth: 33,
maxDepth: 32,
limitType: 'depth',
limit: 32,
},
});
return {};
},
);
const manager = new McpClientManager();
await manager.getRegularTools(
[{ name: 'safe_server', url: 'https://safe.example.com/mcp' }],
logger as never,
);
expect(logger.warn).toHaveBeenCalledWith(
'Skipped MCP tool with unsupported schema',
expect.objectContaining({
toolName: 'deep_tool',
source: 'external MCP',
path: '$.input',
limitType: 'depth',
}),
);
});
});
describe('SSRF policy (opt-in)', () => {
it('does not call validateUrl when no validator is supplied', async () => {
const manager = new McpClientManager();

View File

@ -4,7 +4,10 @@ import type { Result } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { isSafeMcpIdentifierName } from '../agent/mcp-tool-name-validation';
import { sanitizeMcpToolSchemas } from '../agent/sanitize-mcp-schemas';
import type { McpSchemaSanitizationError } from '../agent/sanitize-mcp-schemas';
import type { Logger } from '../logger';
import type { McpServerConfig } from '../types';
/**
@ -32,6 +35,37 @@ function buildMcpServers(configs: McpServerConfig[]): Record<string, McpServerEn
return servers;
}
function warnSkippedMcpSchema(logger: Logger | undefined, source: string) {
return (error: McpSchemaSanitizationError) => {
logger?.warn('Skipped MCP tool with unsupported schema', {
toolName: error.details.toolName,
source,
path: error.details.path,
depth: error.details.depth,
maxDepth: error.details.maxDepth,
limitType: error.details.limitType,
limit: error.details.limit,
reason: error.message,
});
};
}
function getSafeMcpServers(
configs: McpServerConfig[],
logger: Logger | undefined,
source: string,
): McpServerConfig[] {
return configs.filter((config) => {
if (isSafeMcpIdentifierName(config.name)) return true;
logger?.warn('Skipped MCP server with unsafe name', {
serverName: config.name,
source,
});
return false;
});
}
/**
* Owns the lifecycle of MCP client connections used by the orchestrator.
*
@ -63,32 +97,48 @@ export class McpClientManager {
constructor(private readonly ssrfValidator?: SsrfUrlValidator) {}
async getRegularTools(configs: McpServerConfig[]): Promise<ToolsInput> {
if (configs.length === 0) return {};
async getRegularTools(configs: McpServerConfig[], logger?: Logger): Promise<ToolsInput> {
const safeConfigs = getSafeMcpServers(configs, logger, 'external MCP');
if (safeConfigs.length === 0) return {};
const key = JSON.stringify(configs);
const key = JSON.stringify(safeConfigs);
return await this.getOrLoad(
this.regularToolsByKey,
this.inFlightRegularByKey,
key,
async () => {
await this.validateConfigs(configs);
return await this.connectAndListTools(`mcp-${nanoid(6)}`, configs, key);
await this.validateConfigs(safeConfigs);
return await this.connectAndListTools(
`mcp-${nanoid(6)}`,
safeConfigs,
key,
logger,
'external MCP',
);
},
);
}
async getBrowserTools(config: McpServerConfig | undefined): Promise<ToolsInput> {
async getBrowserTools(config: McpServerConfig | undefined, logger?: Logger): Promise<ToolsInput> {
if (!config) return {};
const key = JSON.stringify(config);
const [safeConfig] = getSafeMcpServers([config], logger, 'browser MCP');
if (!safeConfig) return {};
const key = JSON.stringify(safeConfig);
return await this.getOrLoad(
this.browserToolsByKey,
this.inFlightBrowserByKey,
key,
async () => {
await this.validateConfigs([config]);
return await this.connectAndListTools(`browser-mcp-${nanoid(6)}`, [config], key);
await this.validateConfigs([safeConfig]);
return await this.connectAndListTools(
`browser-mcp-${nanoid(6)}`,
[safeConfig],
key,
logger,
'browser MCP',
);
},
);
}
@ -167,9 +217,13 @@ export class McpClientManager {
id: string,
configs: McpServerConfig[],
clientKey: string,
logger: Logger | undefined,
source: string,
): Promise<ToolsInput> {
const client = new MCPClient({ id, servers: buildMcpServers(configs) });
this.clientsByKey.set(clientKey, client);
return sanitizeMcpToolSchemas(await client.listTools());
return sanitizeMcpToolSchemas(await client.listTools(), {
onError: warnSkippedMcpSchema(logger, source),
});
}
}

View File

@ -0,0 +1,140 @@
import { createAllTools, createOrchestratorDomainTools } from '..';
import type { InstanceAiContext } from '../../types';
jest.mock('../../parsers/structured-file-parser', () => ({
isStructuredAttachment: jest.fn(() => false),
}));
jest.mock('../attachments/parse-file.tool', () => ({
createParseFileTool: jest.fn(() => ({ id: 'parse-file' })),
}));
jest.mock('../credentials.tool', () => ({
createCredentialsTool: jest.fn(() => ({ id: 'credentials' })),
}));
jest.mock('../data-tables.tool', () => ({
createDataTablesTool: jest.fn((_context: unknown, scope?: string) => ({
id: scope ? `data-tables-${scope}` : 'data-tables',
})),
}));
jest.mock('../executions.tool', () => ({
createExecutionsTool: jest.fn(() => ({ id: 'executions' })),
}));
jest.mock('../nodes.tool', () => ({
createNodesTool: jest.fn((_context: unknown, scope?: string) => ({
id: scope ? `nodes-${scope}` : 'nodes',
})),
}));
jest.mock('../orchestration/browser-credential-setup.tool', () => ({
createBrowserCredentialSetupTool: jest.fn(() => ({ id: 'browser-credential-setup' })),
}));
jest.mock('../orchestration/build-workflow-agent.tool', () => ({
createBuildWorkflowAgentTool: jest.fn(() => ({ id: 'build-workflow-with-agent' })),
}));
jest.mock('../orchestration/complete-checkpoint.tool', () => ({
createCompleteCheckpointTool: jest.fn(() => ({ id: 'complete-checkpoint' })),
}));
jest.mock('../orchestration/delegate.tool', () => ({
createDelegateTool: jest.fn(() => ({ id: 'delegate' })),
}));
jest.mock('../orchestration/plan-with-agent.tool', () => ({
createPlanWithAgentTool: jest.fn(() => ({ id: 'plan' })),
}));
jest.mock('../orchestration/plan.tool', () => ({
createPlanTool: jest.fn(() => ({ id: 'create-tasks' })),
}));
jest.mock('../orchestration/report-verification-verdict.tool', () => ({
createReportVerificationVerdictTool: jest.fn(() => ({ id: 'report-verification-verdict' })),
}));
jest.mock('../orchestration/verify-built-workflow.tool', () => ({
createVerifyBuiltWorkflowTool: jest.fn(() => ({ id: 'verify-built-workflow' })),
}));
jest.mock('../research.tool', () => ({
createResearchTool: jest.fn(() => ({ id: 'research' })),
}));
jest.mock('../shared/ask-user.tool', () => ({
createAskUserTool: jest.fn(() => ({ id: 'ask-user' })),
}));
jest.mock('../task-control.tool', () => ({
createTaskControlTool: jest.fn(() => ({ id: 'task-control' })),
}));
jest.mock('../workflows/apply-workflow-credentials.tool', () => ({
createApplyWorkflowCredentialsTool: jest.fn(() => ({ id: 'apply-workflow-credentials' })),
}));
jest.mock('../workflows/build-workflow.tool', () => ({
createBuildWorkflowTool: jest.fn(() => ({ id: 'build-workflow' })),
}));
jest.mock('../workflows.tool', () => ({
createWorkflowsTool: jest.fn((_context: unknown, scope?: string) => ({
id: scope ? `workflows-${scope}` : 'workflows',
})),
}));
jest.mock('../workspace.tool', () => ({
createWorkspaceTool: jest.fn(() => ({ id: 'workspace' })),
}));
function makeContext(): InstanceAiContext {
return {
userId: 'user-a',
logger: { warn: jest.fn() },
} as unknown as InstanceAiContext;
}
describe('domain tool construction', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('creates the native full domain tool map', () => {
const context = makeContext();
const domainTools = createAllTools(context);
expect(domainTools).toMatchObject({
workflows: { id: 'workflows' },
executions: { id: 'executions' },
credentials: { id: 'credentials' },
'data-tables': { id: 'data-tables' },
workspace: { id: 'workspace' },
research: { id: 'research' },
nodes: { id: 'nodes' },
'ask-user': { id: 'ask-user' },
'build-workflow': { id: 'build-workflow' },
});
});
it('creates the native orchestrator domain tool map', () => {
const context = makeContext();
const orchestratorTools = createOrchestratorDomainTools(context);
expect(orchestratorTools).toMatchObject({
workflows: { id: 'workflows-orchestrator' },
executions: { id: 'executions' },
credentials: { id: 'credentials' },
'data-tables': { id: 'data-tables-orchestrator' },
workspace: { id: 'workspace' },
research: { id: 'research' },
nodes: { id: 'nodes-orchestrator' },
'ask-user': { id: 'ask-user' },
});
});
});

View File

@ -21,6 +21,11 @@ const CONFIRMATION_PAYLOAD = {
options: ['denyOnce', 'allowOnce', 'allowForSession'],
};
const CONFIRMATION_PAYLOAD_WITH_UNSUPPORTED_OPTION = {
...CONFIRMATION_PAYLOAD,
options: ['denyOnce', 'alwaysAllow', 'allowOnce'],
};
const PLAIN_CONFIRMATION_ERROR: McpToolCallResult = {
content: [
{
@ -31,6 +36,18 @@ const PLAIN_CONFIRMATION_ERROR: McpToolCallResult = {
isError: true,
};
const PLAIN_CONFIRMATION_ERROR_WITH_UNSUPPORTED_OPTION: McpToolCallResult = {
content: [
{
type: 'text',
text: `${GATEWAY_CONFIRMATION_REQUIRED_PREFIX}${JSON.stringify(
CONFIRMATION_PAYLOAD_WITH_UNSUPPORTED_OPTION,
)}`,
},
],
isError: true,
};
const JSON_ENVELOPE_CONFIRMATION_ERROR: McpToolCallResult = {
content: [
{
@ -108,6 +125,114 @@ describe('createToolsFromLocalMcpServer', () => {
expect(() => createToolsFromLocalMcpServer(server)).not.toThrow();
expect(createToolsFromLocalMcpServer(server)['bad_tool']).toBeDefined();
});
it('skips tools with invalid names', () => {
const logger = { warn: jest.fn() };
const server = makeMockServer([
{ ...SAMPLE_TOOL, name: 'bad tool' },
{ ...SAMPLE_TOOL, name: 'read_file' },
]);
const tools = createToolsFromLocalMcpServer(server, logger as never);
expect(tools['bad tool']).toBeUndefined();
expect(tools.read_file).toBeDefined();
expect(logger.warn).toHaveBeenCalledWith(
'Skipped local gateway MCP tool with unsafe name',
expect.objectContaining({
source: 'local gateway MCP',
toolName: 'bad tool',
}),
);
});
it('skips tools with unsafe object key names', () => {
const logger = { warn: jest.fn() };
const server = makeMockServer([
{ ...SAMPLE_TOOL, name: 'constructor' },
{ ...SAMPLE_TOOL, name: 'read_file' },
]);
const tools = createToolsFromLocalMcpServer(server, logger as never);
expect(Object.prototype.hasOwnProperty.call(tools, 'constructor')).toBe(false);
expect(tools.read_file).toBeDefined();
expect(logger.warn).toHaveBeenCalledWith(
'Skipped local gateway MCP tool with unsafe name',
expect.objectContaining({
source: 'local gateway MCP',
toolName: 'constructor',
}),
);
});
it('skips normalized name collisions between local gateway tools', () => {
const logger = { warn: jest.fn() };
const server = makeMockServer([
{ ...SAMPLE_TOOL, name: 'custom_tool' },
{ ...SAMPLE_TOOL, name: 'custom-tool' },
]);
const tools = createToolsFromLocalMcpServer(server, logger as never);
expect(tools.custom_tool).toBeDefined();
expect(tools['custom-tool']).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Skipped local gateway MCP tool with unsafe name',
expect.objectContaining({
source: 'local gateway MCP',
toolName: 'custom-tool',
}),
);
});
it('skips compatibility-normalized non-ASCII tool names', () => {
const logger = { warn: jest.fn() };
const server = makeMockServer([
{ ...SAMPLE_TOOL, name: '' },
{ ...SAMPLE_TOOL, name: 'read_file' },
]);
const tools = createToolsFromLocalMcpServer(server, logger as never);
expect(tools['']).toBeUndefined();
expect(tools.read_file).toBeDefined();
expect(logger.warn).toHaveBeenCalledWith(
'Skipped local gateway MCP tool with unsafe name',
expect.objectContaining({
source: 'local gateway MCP',
toolName: '',
}),
);
});
it('skips oversized raw schemas before tool construction', () => {
const logger = { warn: jest.fn() };
const properties = Object.fromEntries(
Array.from({ length: 251 }, (_, index) => [`field_${index}`, { type: 'string' }]),
);
const server = makeMockServer([
{
...SAMPLE_TOOL,
name: 'huge_tool',
inputSchema: { type: 'object', properties },
},
{ ...SAMPLE_TOOL, name: 'read_file' },
]);
const tools = createToolsFromLocalMcpServer(server, logger as never);
expect(tools.huge_tool).toBeUndefined();
expect(tools.read_file).toBeDefined();
expect(logger.warn).toHaveBeenCalledWith(
'Skipped local gateway MCP tool with unsupported schema',
expect.objectContaining({
source: 'local gateway MCP',
toolName: 'huge_tool',
limitType: 'objectProperties',
}),
);
});
});
describe('execute — first-call path', () => {
@ -169,6 +294,22 @@ describe('createToolsFromLocalMcpServer', () => {
});
});
it('filters unsupported confirmation options after parsing the daemon payload', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(PLAIN_CONFIRMATION_ERROR_WITH_UNSUPPORTED_OPTION);
const suspend = jest.fn().mockResolvedValue(undefined);
const execute = getExecute(server);
await execute({ filePath: 'test.ts' }, makeCtx({ suspend }));
expect(suspend).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(suspend.mock.calls[0][0].resourceDecision).toMatchObject({
...CONFIRMATION_PAYLOAD,
options: ['denyOnce', 'allowOnce'],
});
});
it('calls suspend() for a JSON-envelope GATEWAY_CONFIRMATION_REQUIRED error', async () => {
const server = makeMockServer();
server.callTool.mockResolvedValue(JSON_ENVELOPE_CONFIRMATION_ERROR);

View File

@ -12,7 +12,18 @@ import { z } from 'zod';
import { convertJsonSchemaToZod } from 'zod-from-json-schema-v3';
import type { JSONSchema } from 'zod-from-json-schema-v3';
import { sanitizeMcpToolSchemas } from '../../agent/sanitize-mcp-schemas';
import {
addSafeMcpTools,
createClaimedToolNames,
McpToolNameValidationError,
validateMcpToolName,
} from '../../agent/mcp-tool-name-validation';
import {
assertMcpJsonSchemaWithinLimits,
McpSchemaSanitizationError,
sanitizeMcpToolSchemas,
} from '../../agent/sanitize-mcp-schemas';
import type { Logger } from '../../logger';
import type { LocalMcpServer } from '../../types';
// ---------------------------------------------------------------------------
@ -27,15 +38,28 @@ const gatewayConfirmationSuspendSchema = z.object({
resourceDecision: gatewayConfirmationRequiredPayloadSchema,
});
const gatewayResourceDecisionSchema = z.enum(['denyOnce', 'allowOnce', 'allowForSession']);
const gatewayConfirmationRequiredWirePayloadSchema =
gatewayConfirmationRequiredPayloadSchema.extend({
options: z.array(z.string()),
});
const gatewayConfirmationResumeSchema = z.object({
approved: z.boolean(),
resourceDecision: z.string().optional(),
resourceDecision: gatewayResourceDecisionSchema.optional(),
});
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function isGatewayResourceDecision(
option: string,
): option is z.infer<typeof gatewayResourceDecisionSchema> {
return gatewayResourceDecisionSchema.safeParse(option).success;
}
function tryParseGatewayConfirmationRequired(
result: McpToolCallResult,
): GatewayConfirmationRequiredPayload | null {
@ -66,8 +90,13 @@ function tryParseGatewayConfirmationRequired(
const json = JSON.parse(
candidate.slice(GATEWAY_CONFIRMATION_REQUIRED_PREFIX.length),
) as unknown;
const parsed = gatewayConfirmationRequiredPayloadSchema.safeParse(json);
return parsed.success ? parsed.data : null;
const parsed = gatewayConfirmationRequiredWirePayloadSchema.safeParse(json);
if (!parsed.success) return null;
const options = parsed.data.options.filter(isGatewayResourceDecision);
if (options.length === 0) return null;
return { ...parsed.data, options };
} catch {
return null;
}
@ -77,6 +106,33 @@ function tryParseGatewayConfirmationRequired(
// Factory
// ---------------------------------------------------------------------------
const LOCAL_GATEWAY_MCP_SOURCE = 'local gateway MCP';
function warnSkippedLocalMcpSchema(logger: Logger | undefined) {
return (error: McpSchemaSanitizationError) => {
logger?.warn('Skipped local gateway MCP tool with unsupported schema', {
toolName: error.details.toolName,
source: LOCAL_GATEWAY_MCP_SOURCE,
path: error.details.path,
depth: error.details.depth,
maxDepth: error.details.maxDepth,
limitType: error.details.limitType,
limit: error.details.limit,
reason: error.message,
});
};
}
function warnSkippedLocalMcpTool(logger: Logger | undefined) {
return (error: McpToolNameValidationError) => {
logger?.warn('Skipped local gateway MCP tool with unsafe name', {
toolName: error.toolName,
source: error.source,
reason: error.message,
});
};
}
/**
* Build Mastra tools dynamically from the MCP tools advertised by a connected
* local MCP server (e.g. the computer-use daemon).
@ -94,13 +150,40 @@ function tryParseGatewayConfirmationRequired(
* The `toModelOutput` callback converts MCP content blocks (text and image)
* into the AI SDK's multimodal format so the LLM receives images.
*/
export function createToolsFromLocalMcpServer(server: LocalMcpServer): ToolsInput {
export function createToolsFromLocalMcpServer(server: LocalMcpServer, logger?: Logger): ToolsInput {
const tools: ToolsInput = {};
const claimedToolNames = createClaimedToolNames([]);
const warnTool = warnSkippedLocalMcpTool(logger);
const warnSchema = warnSkippedLocalMcpSchema(logger);
for (const mcpTool of server.getAvailableTools()) {
const toolName = mcpTool.name;
const description = mcpTool.description ?? toolName;
try {
const normalizedName = validateMcpToolName(toolName, LOCAL_GATEWAY_MCP_SOURCE);
const claimedBy = claimedToolNames.get(normalizedName);
if (claimedBy) {
throw new McpToolNameValidationError(
`MCP tool "${toolName}" from ${LOCAL_GATEWAY_MCP_SOURCE} conflicts with "${claimedBy}"`,
toolName,
LOCAL_GATEWAY_MCP_SOURCE,
);
}
assertMcpJsonSchemaWithinLimits(mcpTool.inputSchema, { toolName });
claimedToolNames.set(normalizedName, toolName);
} catch (error) {
if (error instanceof McpToolNameValidationError) {
warnTool(error);
continue;
}
if (error instanceof McpSchemaSanitizationError) {
warnSchema(error);
continue;
}
throw error;
}
let inputSchema: z.ZodTypeAny;
try {
// Convert JSON Schema → Zod (v3) so the LLM sees the actual parameter shapes.
@ -208,5 +291,14 @@ export function createToolsFromLocalMcpServer(server: LocalMcpServer): ToolsInpu
tools[toolName] = tool;
}
return sanitizeMcpToolSchemas(tools);
const sanitizedTools = sanitizeMcpToolSchemas(tools, {
onError: warnSkippedLocalMcpSchema(logger),
});
const safeTools: ToolsInput = {};
addSafeMcpTools(safeTools, sanitizedTools, {
source: LOCAL_GATEWAY_MCP_SOURCE,
claimedToolNames: createClaimedToolNames([]),
warn: warnTool,
});
return safeTools;
}

View File

@ -1,10 +1,11 @@
import type { ToolsInput } from '@mastra/core/agent';
import { isStructuredAttachment } from '../parsers/structured-file-parser';
import type { InstanceAiContext, OrchestrationContext } from '../types';
import { createParseFileTool } from './attachments/parse-file.tool';
import { createCredentialsTool } from './credentials.tool';
import { createDataTablesTool } from './data-tables.tool';
import { createExecutionsTool } from './executions.tool';
import { createToolsFromLocalMcpServer } from './filesystem/create-tools-from-mcp-server';
import { createNodesTool } from './nodes.tool';
import { createBrowserCredentialSetupTool } from './orchestration/browser-credential-setup.tool';
import { createBuildWorkflowAgentTool } from './orchestration/build-workflow-agent.tool';
@ -26,7 +27,7 @@ import { createWorkspaceTool } from './workspace.tool';
* Creates all native n8n domain tools with the full action surface.
* Used for delegate/builder tool resolution sub-agents get unrestricted access.
*/
export function createAllTools(context: InstanceAiContext) {
export function createAllTools(context: InstanceAiContext): ToolsInput {
return {
workflows: createWorkflowsTool(context),
executions: createExecutionsTool(context),
@ -37,7 +38,6 @@ export function createAllTools(context: InstanceAiContext) {
nodes: createNodesTool(context),
'ask-user': createAskUserTool(),
'build-workflow': createBuildWorkflowTool(context),
...(context.localMcpServer ? createToolsFromLocalMcpServer(context.localMcpServer) : {}),
...(context.currentUserAttachments?.some(isStructuredAttachment)
? { 'parse-file': createParseFileTool(context) }
: {}),
@ -48,7 +48,7 @@ export function createAllTools(context: InstanceAiContext) {
* Creates orchestrator-scoped domain tools restricted action surfaces
* for tools where the orchestrator should not have write/builder access.
*/
export function createOrchestratorDomainTools(context: InstanceAiContext) {
export function createOrchestratorDomainTools(context: InstanceAiContext): ToolsInput {
return {
workflows: createWorkflowsTool(context, 'orchestrator'),
executions: createExecutionsTool(context),
@ -58,7 +58,6 @@ export function createOrchestratorDomainTools(context: InstanceAiContext) {
research: createResearchTool(context),
nodes: createNodesTool(context, 'orchestrator'),
'ask-user': createAskUserTool(),
...(context.localMcpServer ? createToolsFromLocalMcpServer(context.localMcpServer) : {}),
};
}

View File

@ -115,7 +115,10 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
if (gatewayBrowserTools.length > 0 && context.localMcpServer) {
// Gateway path: create Mastra tools from gateway, keep only browser category tools
const gatewayBrowserNames = new Set(gatewayBrowserTools.map((t) => t.name));
const allGatewayTools = createToolsFromLocalMcpServer(context.localMcpServer);
const allGatewayTools = createToolsFromLocalMcpServer(
context.localMcpServer,
context.logger,
);
for (const [name, tool] of Object.entries(allGatewayTools)) {
if (gatewayBrowserNames.has(name)) {
browserTools[name] = tool;

View File

@ -11,11 +11,25 @@ import ConfirmationFooter from './ConfirmationFooter.vue';
import ConfirmationPreview from './ConfirmationPreview.vue';
import SplitButton from './SplitButton.vue';
type InstanceGatewayResourceDecision = 'denyOnce' | 'allowOnce' | 'allowForSession';
const INSTANCE_GATEWAY_RESOURCE_DECISIONS = [
'denyOnce',
'allowOnce',
'allowForSession',
] as const satisfies readonly InstanceGatewayResourceDecision[];
function isInstanceGatewayResourceDecision(
value: string,
): value is InstanceGatewayResourceDecision {
return (INSTANCE_GATEWAY_RESOURCE_DECISIONS as readonly string[]).includes(value);
}
const props = defineProps<{
requestId: string;
resource: string;
description: string;
options: string[];
options: InstanceGatewayResourceDecision[];
}>();
const i18n = useI18n();
@ -24,23 +38,21 @@ const rootStore = useRootStore();
const store = useInstanceAiStore();
interface OptionEntry {
decision: string;
decision: InstanceGatewayResourceDecision;
label: string;
}
const DECISION_LABELS: Record<string, string> = {
const DECISION_LABELS: Record<InstanceGatewayResourceDecision, string> = {
allowOnce: i18n.baseText('instanceAi.gatewayConfirmation.allowOnce'),
allowForSession: i18n.baseText('instanceAi.gatewayConfirmation.allowForSession'),
denyOnce: i18n.baseText('instanceAi.gatewayConfirmation.denyOnce'),
};
const KNOWN_DECISIONS = new Set(['allowOnce', 'allowForSession', 'denyOnce']);
function getDecisionLabel(decision: string): string {
return DECISION_LABELS[decision] ?? decision;
function getDecisionLabel(decision: InstanceGatewayResourceDecision): string {
return DECISION_LABELS[decision];
}
function optionEntry(decision: string): OptionEntry {
function optionEntry(decision: InstanceGatewayResourceDecision): OptionEntry {
return { decision, label: getDecisionLabel(decision) };
}
@ -53,17 +65,13 @@ const approvePrimary = computed(() =>
);
const approveDropdownItems = computed(() => {
const items: Array<ActionDropdownItem<string>> = [];
const items: Array<ActionDropdownItem<InstanceGatewayResourceDecision>> = [];
if (props.options.includes('allowForSession'))
items.push({ id: 'allowForSession', label: getDecisionLabel('allowForSession') });
return items;
});
const otherOptions = computed<OptionEntry[]>(() =>
props.options.filter((d) => !KNOWN_DECISIONS.has(d)).map(optionEntry),
);
async function confirm(decision: string) {
async function confirm(decision: InstanceGatewayResourceDecision) {
const tc = store.findToolCallByRequestId(props.requestId);
const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
const eventProps = {
@ -93,16 +101,6 @@ async function confirm(decision: string) {
</div>
<ConfirmationFooter>
<!-- Unknown options not in the standard set -->
<N8nButton
v-for="opt in otherOptions"
:key="opt.decision"
variant="outline"
size="medium"
:label="opt.label"
@click="confirm(opt.decision)"
/>
<!-- Deny side -->
<N8nButton
v-if="denyPrimary"
@ -122,7 +120,7 @@ async function confirm(decision: string) {
data-test-id="gateway-decision-approve"
caret-aria-label="More approve options"
@click="confirm(approvePrimary.decision)"
@select="confirm"
@select="(id: string) => isInstanceGatewayResourceDecision(id) && confirm(id)"
/>
</template>
</ConfirmationFooter>

View File

@ -6,6 +6,7 @@ import {
isSafeObjectKey,
type InstanceAiConfirmation,
type InstanceAiConfirmRequest,
type InstanceAiResourceDecision,
type InstanceAiAttachment,
type InstanceAiEvent,
type InstanceAiMessage,
@ -794,7 +795,10 @@ export function createThreadRuntime(initialThreadId: string, hooks: ThreadRuntim
}
}
async function confirmResourceDecision(requestId: string, decision: string): Promise<void> {
async function confirmResourceDecision(
requestId: string,
decision: InstanceAiResourceDecision,
): Promise<void> {
resolveConfirmation(requestId, 'approved');
await confirmAction(requestId, { kind: 'resourceDecision', resourceDecision: decision });
}