Audit logging for n8n workflows that call MCP tools

Audit logging for n8n workflows that call MCP tools

If you run an n8n workflow that calls MCP tools, here’s something you’ve probably hit. The execution log shows the MCP Client Tool node ran. It doesn’t show what the agent asked the tool to do, or what it got back.

That’s a gap. If the agent loops, or hits a tool you didn’t expect, or sends arguments you wouldn’t have approved, the execution log just says “MCP Client Tool: success.”

I built Cordon for this gap.

What Cordon is

Cordon is an MCP gateway that sits between your MCP client and any upstream MCP server, logs every tool call, and lets you write policies that block or pause calls before they fire. Open source, MIT, free.

n8n’s MCP Client Tool node takes a URL and a bearer token. That’s also exactly what Cordon’s HTTP transport exposes. So the swap is a one-field change. Point the node at Cordon, point Cordon at the upstream server, and you get an audit trail of every call your agent makes through n8n.

Setup

Five minutes if you already have n8n running and at least one MCP server you want to put behind it.

1. Install Cordon

npm install -g @getcordon/cli

2. Write a config

Cordon reads a cordon.config.ts in the directory you run it from. Point it at whatever MCP server your n8n workflow currently uses.

import { defineConfig } from '@getcordon/policy';

export default defineConfig({
  servers: [
    {
      name: 'filesystem',
      transport: 'stdio',
      command: 'npx',
      args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/dir'],
      policy: 'allow',
    },
  ],
  gateway: {
    transport: 'http',
    port: 7777,
    authToken: process.env.CORDON_GATEWAY_TOKEN,
  },
  audit: { enabled: true, output: 'stdout' },
});

3. Start the gateway

export CORDON_GATEWAY_TOKEN=$(openssl rand -hex 16)
cordon start --http

Cordon spawns the upstream MCP server as a child process and listens on http://127.0.0.1:7777/mcp. The stderr line you’re waiting for looks like this.

[cordon] HTTP gateway listening on http://127.0.0.1:7777/mcp

4. Point n8n at Cordon

In your n8n workflow, open the MCP Client Tool node. Set Server Transport to HTTP Streamable. The SSE option is deprecated, and Cordon serves Streamable HTTP at /mcp. Set the endpoint URL to:

http://127.0.0.1:7777/mcp

Set Authentication to Bearer and paste in your CORDON_GATEWAY_TOKEN. Save.

If you’re running n8n in Docker and Cordon outside it, swap 127.0.0.1 for your host’s IP, or run Cordon in the same Docker network and use the container name. If you’re hosting Cordon behind a reverse proxy, the URL becomes https://<your-domain>/mcp and everything else is the same.

What you see

Run your workflow. Cordon prints a line of audit JSON to stderr for every tool call, with the tool name, the arguments, the response timing.

{"event":"tool_call_received","timestamp":1748000000000,"callId":"call_001","toolName":"read_file","args":{"path":"/data/notes.md"}}
{"event":"tool_call_completed","timestamp":1748000000350,"callId":"call_001","toolName":"read_file","durationMs":350}

That’s the visibility n8n’s execution log doesn’t give you. Now you can see what the agent actually asked the tool to do.

If you want a friendlier surface, run cordon login to authenticate against the hosted dashboard and switch your config’s audit output to 'hosted'. Same events, browser table, sortable, exportable.

Gating dangerous calls

You can also tag specific tools with policies. Change a tool’s action to 'approve' and Cordon pauses for a human before the call fires. Slack or terminal prompt. Useful when an agent has access to a tool you don’t fully trust the agent to use unsupervised.

tools: {
  delete_file: { action: 'block', reason: 'Delete is for manual ops only.' },
  write_file:  { action: 'approve', reason: 'Writes need human review.' },
}

Try it

If you’re running n8n workflows with MCP tools, adding Cordon is a config change and a node-URL swap and boom! You’re seeing what’s happening.

→ getcordon.com

If you want a worked example with a real production MCP server in front, here’s how I run Cordon in front of Agent Toolbelt (27 tools my agents call for stock research, schema gen, regex and the small stuff). Same swap, more concrete.

→ Putting my own MCP server behind my own MCP gateway — Marco Arras

1 „Gefällt mir“

The gap you’re describing is real - n8n’s execution log just records “MCP Client Tool: success” with no payload detail. Cordon is a clean solution for teams who need that full audit trail. One lightweight alternative for simpler cases: wrap your MCP Client Tool node in a sub-workflow and add a logging step (write to Google Sheets, Postgres, or even a simple HTTP Request to your log sink) before and after the call - you can capture the tool name, input arguments, and response manually that way without the extra infrastructure.

We’ve also just added support for Open Telemetry, which will give you this data too.

1 „Gefällt mir“

Good call, I hadn’t dug into n8n’s OpenTelemetry support yet. Curious about the granularity, since that’s the whole question for this use case. Do the spans for the MCP Client Tool node carry the actual tool name and the arguments the agent sent and the response it got back, or is it node level execution data like “did it run” and duration?

If the spans carry the tool call payload, then for n8n originated traffic that closes the gap natively and is a clean answer, no proxy needed. If it’s node level, the args and response gap is still there, which is what sent me to a gateway in the first place.

Either way oTel is the right primitive for the n8n side. The place a gateway still adds something is calls to the same mcp server from outside n8n, claude desktop or a script, landing in one trail, plus blocking a call before it fires rather than recording it after. Would love a pointer to what the spans actually capture if you have one.

Appreciate that. And yeah, the subworkflow wrapper is a great call for the simpler case. Sheets or Postgres before and after the node gets you the tool name, args, and response with zero extra infrastructure, which is exactly the right move when you just want a record for one workflow.

The one spot I’d reach for the gateway over the wrapper is when you want to stop a call rather than log it after the fact. The wrapper sees the call once it has already returned. Cordon can pause or block before it fires, which matters when a tool can do something you wouldn’t want to undo. For pure audit though, your approach is lighter and totally legit.

Marco’s OpenTelemetry question is the right acceptance test: for one run, can you reconstruct the actual MCP tool call, not just node success?

I would test the same harmless MCP call three ways:

1. n8n execution / OpenTelemetry span

2. before-and-after wrapper workflow log row

3. gateway audit event, if one is in the path

The fields that decide it for me are tool name, input args or payload hash, response or response hash, policy verdict before execution for mutating tools, and correlation id back to the n8n execution.

If n8n spans carry those, native tracing is the cleanest answer for n8n-only traffic. If they do not, a wrapper is enough for one workflow and a gateway earns its keep when multiple clients hit the same MCP server or when you need to block before the tool fires.

That’s pretty interesting - it blocks tool calls, but can it also block API requests to certain endpoints that those tools might call or redact data like RequestRocket does? We’ve still trying to figure out where the authorization layer should belong. It needs to sit between the AI and the upstream API. I’m thinking tool call monitoring gets us part the way there, so will give this a go, but if we have control over the MCP then we also want to govern the API access to a third party system, and we’ve been usign RR for that to date.

Late reply, sorry.

Your framing is the right test though, and it deserves a straight answer rather than a vague one. So here is Cordon’s audit event against your five fields.

**Tool name.** Yes. Server name and tool name, plus a proxy name so you can tell which instance saw it.

**Input args or payload hash.** Yes, the full args rather than a hash. The JSON export includes them. The CSV export deliberately leaves them out, since that is the one people tend to hand to someone else.

**Response or response hash.** No. This is the real gap in my answer. The event records whether the call errored and the error text, but not the response body or a hash of it. So you can reconstruct what was asked and what the verdict was, and whether it failed, but not what came back.

**Policy verdict before execution.** Yes, and this is the part I would actually defend. The verdict is its own event, emitted before the call goes upstream, not something inferred afterward. A mutating call moves through received, then either blocked or allowed, or approval_requested followed by approved or denied, and only then completed or errored. If a call-graph rule fired, the event carries which rule and the tool that came before it.

**Correlation id back to the n8n execution.** No. There is a callId that ties one tool call’s lifecycle events together, but nothing carries n8n’s execution id, so you cannot join a Cordon trail to an n8n run today. That is the field I would most want, and your list is what made me think hardest about it.

So three of five, and the two missing ones are worth knowing before anyone picks based on my post.

One thing that is not on your list but falls out of the same record. Every event carries the tool that preceded it, and the call-graph check flags when the previous tool’s output shows up again in this call’s arguments. So you can see read-then-write shapes rather than a series of independently allowed calls. That is the part I have not found a way to get from a span or a wrapper, since both look at one call at a time.

Your last paragraph is the honest summary. For n8n only traffic, if the spans carry those fields then native tracing wins on simplicity, and I still do not know whether they do. The gateway earns its keep when more than one client hits the same server, or when you need to stop a call rather than record it.

No on both, and I would rather say that plainly than fudge it.

Cordon sits at the MCP protocol boundary. It sees the tool call. Which server, which tool, the arguments the model passed, whether it errored. It does not sit on the wire between the MCP server and the API that server calls. So if one tool fans out to three endpoints internally, I see one tool call and not three requests. Endpoint level blocking is not something I can honestly claim.

One narrow exception, so you can judge it yourself. If the endpoint is itself an argument to the tool, a generic http_request tool taking a url, then it is visible and gateable in principle. But there is no url matcher today. The only argument aware policies are the SQL ones, which parse a named argument and fail closed when they cannot parse it. Everything else keys off the tool name.

Redaction is also a no, and that one is a deliberate choice rather than a gap I am quiet about. I looked at regex based DLP early and dropped it. A pattern list catches the shapes you already thought of and misses the rest, and it lets you make a compliance claim you cannot actually stand behind. Better not to ship it than to ship it as a checkbox.

So on your real question, keep RequestRocket. Method, path, headers, body, and JSON path filters on the response is the right shape for the API boundary, and I am not trying to replace that.

What I would argue sits a layer above it. An HTTP proxy judges each request on its own merits. It cannot see that the agent read your customer table and is now calling a write tool, because that shape lives in the order of the calls rather than in any single request. The sequence is what Cordon gates on. Same for a human checkpoint, where the call pauses before it fires instead of alerting after. And when several clients hit the same MCP server, n8n and a desktop client and a script all land in one trail.

The other piece is intent. By the time a request reaches the API layer the model’s choice has been flattened into HTTP. At the MCP boundary you still have which tool it picked and what it passed.

Where the two layers genuinely do not meet yet is a correlation id. There is no way today to join “the agent called sync_customer” to the four API requests that came out of it and the auth decision on each. havencraft raised correlation id further up this thread and I did not have a good answer then either. Since you are already running both layers, I would be curious whether you have solved that join, because I suspect that is the actual missing piece rather than either layer being in the wrong place.

What are you governing at each layer today? If the API side already covers the destructive calls, the tool call layer might not add much for you. It earns its place when the risk is in the sequence, or when you want a person in the loop before the thing runs.