Running a LangGraph StateGraph under askalf's control plane

Recently I put a CrewAI Flow under askalf's control plane and showed the receipts. The obvious next question, the one an engineer who already uses a different framework will ask, is whether that was a CrewAI trick or a structural property. So I did it again with a framework that has nothing to do with CrewAI: LangGraph. Same gate, same three guarantees, no changes to LangGraph or the tool server.

Update, July 16, 2026: warden has since been renamed redstamp (github.com/askalf/redstamp): same firewall, same code, same guarantees; this post keeps the name it shipped with, and every repository link redirects.

The architecture in one sentence

LangGraph.js talks to MCP servers through the official @langchain/mcp-adapters client. askalf's warden ships warden-mcp, a drop-in stdio proxy that wraps any MCP server. Point the adapter's command at warden-mcp instead of your tool server directly, and every tool call the graph makes, and every tool it's even allowed to load, is risk-classified, policy-checked, injection-screened, and written to a tamper-evident audit, before the tool server ever sees it.

LangGraph node ──▶ MultiServerMCPClient (stdio) ──▶ warden-mcp (askalf gate) ──▶ notes MCP server
  START/END             the MCP client                 firewall + audit              real tools

The graph doesn't know the gate is there. It can't opt out. That's the point, and it's the same point I made with CrewAI, which is exactly why it's worth showing twice.

What the example actually does

The artifact (code in examples/langgraph-warden/) is a genuine @langchain/langgraph StateGraph: typed Annotation state, START/END edges, real node functions wired by addEdge, compiled and invoked. Its tools come from the LangChain MCP adapter, pointed at the gate. The downstream MCP server has four real tools and one deliberately poisoned one. The four nodes run in order:

  1. discover: reads the tool list the gate hands the graph.
  2. write_governed_note: writes a note through the gate (benign; allowed).
  3. attempt_destructive_call: asks the shell tool to wipe the filesystem.
  4. read_back: reads the note back to prove the write persisted.

The downstream server has a fifth tool, exfiltrate_helper, whose description is a prompt-injection instruction telling any reading model to exfiltrate SSH keys and .env secrets. The graph never loads it.

Three things that happened, each with a receipt

Every line below is copied from examples/langgraph-warden/evidence/, captured from a real run that's checked into the repo.

1. The poisoned tool was stripped before the graph could load it. warden's tools/list filter runs before the MCP response reaches the adapter, so client.getTools() returned exactly four tools, ['list_notes', 'read_note', 'run_command', 'write_note'], and the gate logged why the fifth is gone:

[warden] poisoned tool from server: exfiltrate_helper
  (instruction-override, exfiltration intent, references a sensitive path (.ssh/.env/credentials/...))

The graph never had exfiltrate_helper as a callable tool. There was nothing to choose wrong.

2. The destructive call was blocked at the gate. The attempt_destructive_call node asked run_command to recursively wipe the filesystem root. The gate classified it black. Here's the nice part of doing this in LangChain: warden returns an isError MCP result, and the adapter surfaces that as a thrown ToolException, the framework's own error path carries the block, so the node just catches it:

MCP tool 'run_command' on server 'notes' returned an error:
  ⛔ warden blocked this call (black): ☠ recursive force-delete of root/home/system/glob; ⚠ file deletion

The graph continued cleanly to read_back. The tool server saw nothing.

3. Every verdict is in a hash-chained audit, and tampering breaks it. audit.jsonl has three entries, allow (write), block (destructive shell call), allow (read), each chained by SHA-256 over the previous entry's hash. Running the verifier:

1) intact chain  -> {"ok":true,"entries":3}
2) after flipping the blocked verdict (entry 1) to "allow" -> {"ok":false,"at":1}

You can't quietly change “block” to “allow” in the audit log after the fact. The chain breaks at the tampered entry, and the verifier names exactly which one.

In between, the benign write_note was allowed (yellow: a reversible file write) and read_back confirmed the file round-tripped intact, Written by a LangGraph StateGraph node, through the warden gate., so this isn't a gate that blocks everything; it's a gate that blocks the right things.

The warden policy that produced these results is four lines:

{
  "allow": ["write(*)", "read(*)"],
  "deny": ["shell(rm:*)"],
  "egressAllow": ["api.anthropic.com", "github.com"],
  "writeRoots": null
}

Worth noting: the rm -rf / block doesn't depend on that deny line at all. warden's classifier rates a recursive root delete black on its own; the policy is there to express your intent, not to carry the safety floor.

What this establishes, and what it doesn't

Like the CrewAI proof, this is deterministic tool-surface governance. The graph's tool surface is fully governed: what tools are visible, what calls are allowed, what happens when a node (or a prompt injection) tries to go out of bounds. Every decision is pre-computed offline, same input, same verdict, no LLM and no network in the gate. That's why the whole example runs with no API key, and why the evidence in evidence/ is reproducible rather than a one-off screenshot.

What it's not yet is an LLM choosing the tools. The nodes here call tools directly; the governance is over those calls. The natural extension is one line: bind the same MCP-loaded tools to a chat model in a graph node (model.bindTools(await client.getTools())), so it's the model's tool choices that run through the gate. Same gate, now in front of the decisions. That's wiring, not research, and I'll publish that variant separately.

One thing worth disclosing for reproducibility: I built and captured this on an Alpine Linux / musl, Node-only host, no Python toolchain. That's the actual reason the framework here is LangGraph.js and the downstream server is a notes_mcp_server.mjs rather than the CrewAI example's .py. Nothing is shimmed or stubbed to make it work: it's pure JavaScript, npm install is the only setup, and the gate (warden-mcp) is byte-for-byte the same one the CrewAI example uses. Provenance is captured in full at evidence/PROVENANCE.txt: @langchain/langgraph 1.4.7, @langchain/mcp-adapters 1.1.3, @modelcontextprotocol/sdk 1.29.0, warden 0.2.1, Node 24.

Why doing it twice is the point

The reason I rebuilt this with a second, unrelated framework isn't to pad a portfolio. It's that warden's MCP proxy layer is a layer below the framework, not inside it. CrewAI and LangGraph share almost nothing architecturally, one is a Python Flow engine, the other a JS state-graph runtime. What they share is that they both speak MCP, and a gate that sits between an MCP client and an MCP server governs them identically, with no integration, no plugin, and no ability for the framework to route around it.

That's what “askalf governs third-party agent frameworks” means in practice. Not one framework, not a special case, a structural position between the agent and its tools, at the protocol level, that holds no matter what's upstream.

The code is at github.com/askalf/warden/tree/master/examples/langgraph-warden. It's runnable, the evidence is in evidence/, and the audit chain is verifiable by anyone.

We build the control-plane layer for autonomous agents: the boundary between an agent and its tools, where “allowed,” “blocked,” and “prove it” actually live. If you're running agents (any framework) and need that boundary to be real, that's the kind of problem we go deep on.

Start a conversation →
← All writing