mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(ai-builder): Validate MCP tool names and schemas (no-changelog) (#29871)
This commit is contained in:
parent
8dd6d12918
commit
273db4be75
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
packages/@n8n/instance-ai/src/tools/__tests__/index.test.ts
Normal file
140
packages/@n8n/instance-ai/src/tools/__tests__/index.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: 'TOOL' },
|
||||
{ ...SAMPLE_TOOL, name: 'read_file' },
|
||||
]);
|
||||
|
||||
const tools = createToolsFromLocalMcpServer(server, logger as never);
|
||||
|
||||
expect(tools['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: 'TOOL',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user