guardToolNode. MCP and unwrapped tools execute inside ToolNode. Graph hooks and interrupt() can't stop tool.invoke. Wrap the node in place; a copy leaves the original executing unguarded tools.How do I secure MCP tools in LangGraph?
MCP and unwrapped tools execute inside ToolNode. Graph hooks and HITL pauses can't stop tool.invoke. guardToolNode is the gate. Wrap that node in place; a copy leaves the original executing unguarded tools.
You mounted a Linear MCP. There is no function in your repository that posts the comment. Wrapping every authored tool() with guardTool leaves that host call untouched. The LangGraph agent guard sits on ToolNode for exactly this case.
import { ToolNode } from "@langchain/langgraph/prebuilt";import { guardToolNode } from "@arcjet/guard/langgraph/v1";import { tokenBucket } from "@arcjet/guard";import { arcjet } from "./arcjet.js";
const mcpLimit = tokenBucket({ bucket: "mcp-access", refillRate: 20, intervalSeconds: 60, maxTokens: 20,});
export const tools = guardToolNode( arcjet, new ToolNode([lookupOrder, ...mcpTools]), { action: ({ toolName }) => `${toolName}.invoked`, rules: ({ toolName }) => [mcpLimit({ key: toolName, requested: 1 })], },);Pass the wrapped node to StateGraph.addNode("tools", tools). Import from @arcjet/guard/langgraph/v1 after npm install @arcjet/guard @langchain/langgraph @langchain/core. There is no unversioned alias.
The action and rules factories receive { toolName }, which is what makes a per-tool policy possible for a tool that you never wrote. Deriving the label from toolName gives every MCP tool its own action, so the console shows linear.create-comment.invoked rather than one bucket for the whole node. Labels are validated as slugs, so lowercase letters, digits, dashes, and dots only.
Why don't graph hooks stop tool.invoke?
LangGraph graph hooks observe a step. They don't sit inside ToolNode.run. When the node calls tool.invoke, the MCP server is already the next hop. A hook that logs after the comment posts is a diary.
interrupt() and interrupt_before=["tools"] pause for a person. A person is a hold. They aren't a deny on linear.create_comment. There is no guardHooks, no guardInterrupt, and no guardApproval for LangGraph. For more information about that trap, see needsApproval and LangGraph interrupt() are not a security policy.
protect() is the HTTP check on a route. The model asks Linear to comment. LangGraph calls Linear from your process.
Why must I wrap ToolNode in place?
ToolNode's constructor captures func as an arrow bound to the instance, and run reads this.tools. A copy that holds a fresh tools array leaves the original node executing unguarded tools. guardToolNode guards the node's tools in place and returns the same node.
A frozen tools array throws at wrap time. Passing an array of tools instead returns guarded copies and leaves the input array alone. Already-branded guardTool tools are skipped so Guard isn't double-called. A second wrap of an already-branded node throws. Tools appended after wrapping (MCP discovered mid-run) are guarded on the next invoke, which is the case that a copy would silently lose.
Helpers default to onGuardError: "deny". If Guard can't be evaluated, the tool doesn't run. The policy factories are try/caught under the same rule, so a rules callback that throws on an unexpected MCP argument shape denies rather than executing unchecked. A DENY conclusion blocks whatever onGuardError is set to; that option only decides the outcome when no decision was reached.
How is this like Eve connections and Mastra MCP?
Eve MCP and OpenAPI connections have no local execute. The gate is guardApproval() on the connection's approval field. For more information about that recipe, see How to secure Eve MCP connections.
Mastra MCP, workspace, and toolset tools also have no local execute. The deny is guardHooks on beforeToolCall. For more information about that recipe, see How to secure Mastra MCP tools.
LangGraph MCP tools run inside ToolNode. The deny is guardToolNode. Three hosts, one fact: a wrap that you put on a function that you wrote doesn't cover a tool the host invokes for you.
Eve, Mastra, and LangGraph all make that miss look like a finished integration. You wrapped every function in the repo. The host still has a second tool list. Linear, GitHub, and filesystem MCP servers live on that list. A sandbox around generated bash doesn't see linear.create_comment. An inbound screen that declined yesterday's jailbreak doesn't see today's tool args.
For more information about the other side, see How to secure an MCP server or AI agent tool calls. You own that handler. This page is a mounted server that you didn't write.
Does interrupt() replace guardToolNode?
LangGraph interrupt() doesn't replace guardToolNode. interrupt() parks the graph. Rubber-stamping every MCP tool doesn't remove a lethal trifecta leg: private data, untrusted text, and an external send in one create_comment.
guardTool still belongs on authored tools that you invoke yourself. Applying both helpers to the same authored tool doesn't double-call the guard: guardToolNode skips already-branded tools. Use guardToolNode for the tools that you didn't wrap.
Wrapping the node is also the only version of this that survives a new MCP server. A per-tool allowlist has to be edited every time the host advertises another tool. guardToolNode sits on the node, so a tool discovered next week arrives already guarded.
If you wrapped only tool(), the Linear comment has already posted. For more information about the rest of the map, see How do I secure a LangGraph JS agent?.
Frequently asked questions
How do I secure MCP tools in LangGraph?
MCP and unwrapped tools execute inside ToolNode. Graph hooks and HITL pauses can't stop tool.invoke. guardToolNode is the gate. Wrap that node in place; a copy leaves the original executing unguarded tools.
Can I copy the ToolNode and wrap the copy?
No. ToolNode's constructor captures func bound to the instance, and run reads this.tools. A copy with a fresh tools array leaves the original unguarded. guardToolNode returns the same node.
Does interrupt() stop an MCP tool?
No. interrupt() parks the graph for a person. That is a hold, not a policy. The deny for MCP inside ToolNode is guardToolNode.
Is this the same problem as Eve connections and Mastra MCP?
Yes. Eve connections have no local execute, so the gate is guardApproval. Mastra MCP has no local execute, so the gate is guardHooks on beforeToolCall. LangGraph MCP runs inside ToolNode, so the gate is guardToolNode.
If I wrap every authored tool, are MCP tools covered?
No. guardTool wraps tool() / StructuredTool that you wrote. MCP tools still execute inside ToolNode. Use guardToolNode for those. Already-branded guardTool tools are skipped so Guard isn't double-called.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Get allow, deny, and redact on agent actions before the side effect.