Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/components/nodes/tools/MCP/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,98 @@
import {
MCPTool,
MCPToolkit,
validateCommandFlags,
validateCommandInjection,
validateArgsForLocalFileAccess,
validateEnvironmentVariables,
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', () => {
Expand Down
55 changes: 55 additions & 0 deletions packages/components/nodes/tools/MCP/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MCPTrustVerifierDecision>

export class MCPToolkit extends BaseToolkit {
tools: Tool[] = []
_tools: ListToolsResult | null = null
Expand All @@ -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<Record<string, string>>
/** 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
Expand Down Expand Up @@ -142,6 +160,41 @@ export class MCPToolkit extends BaseToolkit {
}
}

const assertMCPToolCallTrusted = async (toolkit: MCPToolkit, toolName: string, input: unknown): Promise<void> => {
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}`)
}
Comment on lines +174 to +195
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the trustVerifier hook returns null or undefined, the current implementation will throw a TypeError when attempting to access properties like decision.action or decision.reason (since typeof null === 'object' evaluates to true in JavaScript). To prevent runtime crashes and ensure a fail-secure behavior, we should add a nullish check (== null) before processing the decision.

    if (decision == null) {
        throw new Error('MCP tool call blocked by trust verifier for "' + toolName + '": decision is null or undefined')
    }

    const action =
        typeof decision === 'boolean' ? (decision ? 'allow' : 'deny') : decision.action ?? (decision.allowed === false ? 'deny' : 'allow')

    if (action === 'warn') {
        console.warn(
            'MCP trust verifier warning for tool "' + toolName + '"' +
            (typeof decision === 'object' && decision.reason ? ': ' + decision.reason : '')
        )
        return
    }

    if (action === 'deny') {
        const reason = typeof decision === 'object' && decision.reason ? ': ' + decision.reason : ''
        throw new Error('MCP tool call blocked by trust verifier for "' + toolName + '"' + reason)
    }
References
  1. In JavaScript/TypeScript, use loose equality (== null) as a standard idiom for a 'nullish' check that covers both null and undefined.

}

export async function MCPTool({
toolkit,
name,
Expand All @@ -155,6 +208,8 @@ export async function MCPTool({
}): Promise<Tool> {
return tool(
async (input): Promise<string> => {
await assertMCPToolCallTrusted(toolkit, name, input)

// Create a new client for this request
const toolCallHeaders = await toolkit.getToolCallHeaders?.()
const client = await toolkit.createClient(toolCallHeaders)
Expand Down