Callwitness

Do you know what
your agent actually did?

A recorder that sits between your agents and their tools, hash-chaining what it writes down so the record holds up to someone with no reason to trust you. It records every tool call an agent makes, forwards every byte unchanged, and blocks nothing.

v0.4.4 Python 3.8+ Zero dependencies MIT
$pip install callwitness
Source

Then callwitness demo — a real MCP server recorded in under a minute, with no agent, no API key and nothing to configure.

found

One ordinary call spent
17,000 tokens of context.

A single fetch returned 68.6 KB — roughly seventeen thousand tokens into the context window, for “go read this document”. The agent's very next move was the same call with max_length=2000. It hit the size, worked around it, and said nothing.

Separately, running 86 public MCP servers through the recorder turned up three calls that failed after 16, 21 and 37 seconds and returned nothing at all. A tool that fails fast is visible; one that fails slowly and empty looks to an agent like a tool with nothing to say — it waits, gets nothing, and moves on.

Neither is visible in a terminal, and neither is anyone's fault. They are simply not written down anywhere — which is the whole problem.

callwitness last  ·  callwitness cost --since 7d  ·  callwitness tail --errors

measured

Declared size does not predict
what a server actually hands back.

Rank 65 MCP servers by the schema bytes they declare, then rank them again by what they actually returned. The two top tens share two entries. The typical server moves 13 to 18 places out of 65.

mcp-deepwiki declares 774 bytes of schema and returned 62 KB. mcp-sympy declares 63 KB across 171 tools and returned 198 B. Median response 580 B, p95 36 KB, largest 496 KB, across 140 calls on 82 servers declaring 1,070 tools.

Every published estimate of MCP context cost counts the schemas a server declares. Nobody counted what it delivers, because that means running the servers. The raw data, the harness, every failure, and the script that computes the correlation four different ways are all published.

the results →   ·   how it was measured →   ·   v1.json

seq

Same tool. Same permission.
Two very different acts.

seqbytestooldestinations
11751 send_emailops@acme.com
11863 send_emailbilling@acme.com
11920085 send_emailexfil.example.net, drop@unknown.example!

An allowlist cannot tell these apart. The agent is permitted to send email in all three cases. The difference is how much is leaving and where it is going — the two signals Callwitness records on every call.

why

It blocks nothing, so you can install it on a Tuesday afternoon.

A recorder that can break the thing it records will not be installed anywhere that matters. So two properties come before every feature, and both are tested rather than asserted.

It cannot corrupt

Every message is relayed whether or not the recorder can parse it, and every write to storage is wrapped so a recorder bug never reaches the stream. test_passthrough.py asserts the proxied output is byte-identical to running the server directly.

It cannot delay

0.05ms

What a deliberately half-second-slow observer adds to the gap between two forwarded messages. Before observation moved off the hot path, the same test moved it by 4.2 seconds. The queue drops rather than growing without limit, and counts what it dropped.

use

It wraps the servers you already have.

Point it at your client's config. It shows exactly what would change and writes nothing until you say so — a broken MCP config means a broken agent, which is the one outcome this tool promises not to cause.

$ callwitness install

/Users/you/Library/Application Support/Claude/claude_desktop_config.json
  filesystem
    - npx -y @modelcontextprotocol/server-filesystem /data
    + callwitness run --label filesystem -- npx -y @modelcontextprotocol/server-filesystem /data
  git
    - uvx mcp-server-git --repository /repo
    + callwitness run --label git -- uvx mcp-server-git --repository /repo
  remote-api  SKIPPED: remote server -- needs `callwitness proxy`

2 servers would be wrapped. Nothing has been changed.

Knows Claude Desktop, Claude Code, Cursor, Windsurf and project-local .mcp.json. Remote servers over Streamable HTTP go behind callwitness proxy instead — POST, the SSE response stream, the server-initiated GET stream and session teardown are all relayed verbatim, headers included, so the Mcp-Session-Id handshake works without Callwitness understanding it.

--apply writes it, after a timestamped backup. callwitness uninstall puts everything back.

rules

Then it writes the rules, from traffic you actually saw.

The next tier was going to be YAML you write by hand. But a person typing max_payload: 8KB is guessing at a number they have no way to know — which is the thing this project says the industry is doing wrong. Enforcement without data is guessing with extra steps.

$ callwitness suggest --since 14d

  send_email     max_payload           3.5KB     # p99 observed 2.3KB over n=400; 1.5x headroom
  send_email     destinations_emails   3 allowed # 3 addresses covering 100% of traffic, n=400
  send_email     rate_limit_per_hour   21        # 10.0/hour over 39.9 hours; 2x headroom
? fetch_url      destinations_hosts    --        # 99 distinct hosts across 150 calls -- too varied
                                                #   for an allowlist; reads as a general fetcher
? delete_record  insufficient_data     --        # only 6 calls observed; 30 needed before a
                                                #   threshold means anything

Note what it refuses to do. A tool below 30 calls gets no threshold, because a p99 over n=6 is an anecdote. A destination never seen is not a destination that is forbidden — it may simply not have happened yet, and the output says so. Every line is a hypothesis with its evidence attached.

trap

The attack that raises the limit to permit itself.

If the bad thing already happened while you were watching, it is in the distribution, and a plain percentile quietly makes room for it. In testing, a 29KB exfiltration produced a 43KB proposed ceiling — a limit that would have allowed the exact call the tool exists to catch.

  send_email  max_payload  4.3KB  # p99 of the bulk is 2.9KB; 1.5x headroom.
                                 #   EXCLUDES 1 call above 11.4KB
! send_email  tail_review  1      # 1 call more than 8x the 1.4KB median. A rare
                                 #   enormous call is the most interesting thing
                                 #   here, so it sets no limit until you have
                                 #   looked at it: 28.8KB at 2026-09-09T18:09
                                 #   -> exfil.example.net

So ceilings come from the bulk of a distribution, not all of it. The reference is the median — the one statistic a single enormous call cannot move, which is the point when that call may be the attack. Outliers set no limit; they are named, with timestamps and destinations, and handed to a person. If more than 10% of traffic sits above the threshold it is not a tail, it is the shape, and nothing is excluded.

chain

A record that can be silently rewritten is a convenience, not evidence.

An append-only file is trivially editable by anyone with filesystem access — including a compromised agent running as the same user. So every call commits to the one before it. Editing, deleting, reordering or inserting a record breaks the chain from that point, and verify says where.

$ callwitness verify
BROKEN  filesystem  642 records, breaks at seq 118
                    content does not match its hash: this record was
                    edited after it was written

Exit code 1 on a break, so it works in a cron job without anyone parsing text.

It is tamper-evident, not tamper-proof

Someone who can write to the file can also recompute every hash after a change and produce a chain that verifies. Nothing local can stop that, because the verifier and the attacker read the same file. What defeats it is an anchor the operator does not control, so verify prints the head hash and tells you to store it somewhere the machine cannot reach. That is a deployment decision, and inventing one for you would be worse than naming the gap.

privacy

Redaction is on by default, and destinations survive it.

Tool arguments routinely carry API keys, bearer tokens and connection strings. Without redaction, every install would create a plaintext credential store that did not exist before. Known key formats, credentials inside URLs, sensitively-named parameters and high-entropy tokens are replaced on the write path — never on read, because by then the plaintext is already on disk.

stored as:  postgres://admin:<redacted:url_password>@db.internal

The host is the signal; the password is not. The true pre-redaction byte count is kept, so the volume signal survives. --no-args stores argument shape only and still records destinations — that is deliberate, it is tested, and you should say it out loud to anyone you ask to run this.

Everything stays on the machine that ran it. Nothing is transmitted anywhere.

next

Where this is going.

  1. Now

    Observe. Record every call, block nothing. Both MCP transports, hash-chained, redacted on the write path.

  2. Next

    Independent verification. A log its operator could have edited proves nothing to the person demanding it. Anchoring publishes the chain's head outside your control, so a third party can confirm what was recorded and when.

  3. Then

    Population. Enough recorded deployments to say “this response is unusual for this tool” on a machine’s first day, instead of only after it has built its own history.

    Payload volume × destination reputation first.

Scope is MCP tool calls over stdio and Streamable HTTP. The deprecated two-endpoint HTTP+SSE transport is not covered. Direct API calls made inside agent code need an SDK wrapper, and that is deliberately not in v1.

origin

Why it exists.

Out of an experiment on chain-of-thought faithfulness, which turned up a measurement problem: hint verbalisation rate reads 100% on the reasoning trace and 12% on the user-facing answer, for the same responses. Same data, same model, an order of magnitude apart depending only on where you look.

A field whose headline metric moves by 10× depending on the instrument does not need another opinion about agent risk. It needs somebody to start writing down what actually happens.

$pip install callwitness
PyPI GitHub
offer

If you answer for it.

The person who installs a recorder is usually not the person who needs the recording. If an agent at your company acts on real work — reads a candidate's file, prices a policy, sends something to someone outside — then one day somebody asks what it did. Probably a customer. Possibly a regulator.

Two questions worth answering before that day.

When your agent does something wrong, who comes to you asking what happened — and what do you show them?

If it touches hiring, screening, credit or insurance, it profiles people. Article 6(3) of the EU AI Act does not exempt a system on the ground that it performs only a narrow task if that system profiles natural persons. Article 12 requires automatically recorded logs over the system's lifetime. Can you produce them today?

If the honest answer to either is I don't know, I would like to hear about it. I am not selling a dashboard, and this tool will never block anything your agent does. It records, and the record proves itself.

I work directly with a small number of companies — recording what their agents actually do, and turning it into something they could hand to the person asking. If that is a conversation worth having, write to me.

callwitness.dev@gmail.com Open an issue