diff --git a/packages/components/nodes/tools/MCP/core.test.ts b/packages/components/nodes/tools/MCP/core.test.ts index 2810ee9a75d..893fb382b81 100644 --- a/packages/components/nodes/tools/MCP/core.test.ts +++ b/packages/components/nodes/tools/MCP/core.test.ts @@ -1,4 +1,6 @@ import { + MCPTool, + MCPToolkit, validateCommandFlags, validateCommandInjection, validateArgsForLocalFileAccess, @@ -6,6 +8,91 @@ import { validateMCPServerConfig } from './core' +describe('MCP tool trust verifier', () => { + const createToolkit = () => { + const toolkit = new MCPToolkit({ url: 'https://mcp.example.com', headers: { authorization: 'Bearer static' } }, 'http') + const request = jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) + const close = jest.fn().mockResolvedValue(undefined) + const createClient = jest.fn().mockResolvedValue({ request, close }) + toolkit.createClient = createClient as any + + return { toolkit, request, close, createClient } + } + + it('passes MCP call context to the verifier before dispatching the tool call', async () => { + const { toolkit, request } = createToolkit() + toolkit.trustVerifier = jest.fn().mockReturnValue({ action: 'allow', reason: 'trusted test server' }) + + const mcpTool = await MCPTool({ + toolkit, + name: 'list_records', + description: 'List records', + argsSchema: { type: 'object', properties: {} } + }) + + await mcpTool.invoke({ limit: 5 }) + + expect(toolkit.trustVerifier).toHaveBeenCalledWith({ + transportType: 'http', + serverParams: { url: 'https://mcp.example.com', headers: { authorization: 'Bearer static' } }, + serverUrl: 'https://mcp.example.com', + toolName: 'list_records', + input: { limit: 5 } + }) + expect(request).toHaveBeenCalledWith( + { method: 'tools/call', params: { name: 'list_records', arguments: { limit: 5 } } }, + expect.anything() + ) + }) + + it('blocks denied MCP tool calls before creating a client', async () => { + const { toolkit, createClient } = createToolkit() + toolkit.trustVerifier = jest.fn().mockReturnValue({ action: 'deny', reason: 'untrusted server' }) + + const mcpTool = await MCPTool({ + toolkit, + name: 'delete_records', + description: 'Delete records', + argsSchema: { type: 'object', properties: {} } + }) + + await expect(mcpTool.invoke({ ids: ['1'] })).rejects.toThrow( + 'MCP tool call blocked by trust verifier for "delete_records": untrusted server' + ) + expect(createClient).not.toHaveBeenCalled() + }) + + it('supports boolean verifier decisions', async () => { + const { toolkit, createClient } = createToolkit() + toolkit.trustVerifier = jest.fn().mockResolvedValue(false) + + const mcpTool = await MCPTool({ + toolkit, + name: 'export_data', + description: 'Export data', + argsSchema: { type: 'object', properties: {} } + }) + + await expect(mcpTool.invoke({})).rejects.toThrow('MCP tool call blocked by trust verifier for "export_data"') + expect(createClient).not.toHaveBeenCalled() + }) + + it('fails securely when the verifier returns nullish decisions', async () => { + const { toolkit, createClient } = createToolkit() + toolkit.trustVerifier = jest.fn().mockResolvedValue(null) + + const mcpTool = await MCPTool({ + toolkit, + name: 'transfer_funds', + description: 'Transfer funds', + argsSchema: { type: 'object', properties: {} } + }) + + await expect(mcpTool.invoke({ amount: 100 })).rejects.toThrow('MCP tool call blocked by trust verifier for "transfer_funds"') + expect(createClient).not.toHaveBeenCalled() + }) +}) + describe('MCP Security Validations', () => { describe('validateCommandFlags', () => { describe('npx command', () => { diff --git a/packages/components/nodes/tools/MCP/core.ts b/packages/components/nodes/tools/MCP/core.ts index 5cd552a3f8a..ba24e479154 100644 --- a/packages/components/nodes/tools/MCP/core.ts +++ b/packages/components/nodes/tools/MCP/core.ts @@ -6,6 +6,22 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { checkDenyList, secureFetch } from '../../../src/httpSecurity' +export type MCPTrustVerifierContext = { + transportType: 'stdio' | 'sse' | 'http' + serverParams: StdioServerParameters | any + serverUrl?: string + toolName: string + input: unknown +} + +export type MCPTrustVerifierDecision = + | boolean + | { allowed?: boolean; action?: 'allow' | 'warn' | 'deny'; reason?: string; metadata?: unknown } + | null + | undefined + +export type MCPTrustVerifier = (context: MCPTrustVerifierContext) => MCPTrustVerifierDecision | Promise + export class MCPToolkit extends BaseToolkit { tools: Tool[] = [] _tools: ListToolsResult | null = null @@ -16,6 +32,8 @@ export class MCPToolkit extends BaseToolkit { transportType: 'stdio' | 'sse' | 'http' /** Per-invocation HTTP headers injected at tools/call time; overrides static toolkit headers for the same names. */ getToolCallHeaders?: () => Promise> + /** Optional policy hook that can allow, warn, or deny an MCP tool call before dispatch. */ + trustVerifier?: MCPTrustVerifier constructor(serverParams: StdioServerParameters | any, transportType: 'stdio' | 'sse' | 'http') { super() this.serverParams = serverParams @@ -142,6 +160,41 @@ export class MCPToolkit extends BaseToolkit { } } +const assertMCPToolCallTrusted = async (toolkit: MCPToolkit, toolName: string, input: unknown): Promise => { + if (!toolkit.trustVerifier) return + + const decision = await toolkit.trustVerifier({ + transportType: toolkit.transportType, + serverParams: toolkit.serverParams, + serverUrl: toolkit.serverParams?.url, + toolName, + input + }) + + const action = + typeof decision === 'boolean' + ? decision + ? 'allow' + : 'deny' + : decision == null + ? 'deny' + : decision.action ?? (decision.allowed === false ? 'deny' : 'allow') + + if (action === 'warn') { + console.warn( + `MCP trust verifier warning for tool "${toolName}"${ + typeof decision === 'object' && decision != null && decision.reason ? `: ${decision.reason}` : '' + }` + ) + return + } + + if (action === 'deny') { + const reason = typeof decision === 'object' && decision != null && decision.reason ? `: ${decision.reason}` : '' + throw new Error(`MCP tool call blocked by trust verifier for "${toolName}"${reason}`) + } +} + export async function MCPTool({ toolkit, name, @@ -155,6 +208,8 @@ export async function MCPTool({ }): Promise { return tool( async (input): Promise => { + await assertMCPToolCallTrusted(toolkit, name, input) + // Create a new client for this request const toolCallHeaders = await toolkit.getToolCallHeaders?.() const client = await toolkit.createClient(toolCallHeaders)