building vodou.

Your MCP audit table probably has no producer

The audit table existed since migration 046 and held zero rows for its whole life. What I found instrumenting both MCP client stacks, and three checks for yours.

Chad Priest / / 9 min read

Every MCP gateway architecture I have read puts audit in the control plane. DigitalAPI’s write-up is typical and correct: registry, policy, credential binding, audit search and export sit in the control plane, and the data plane handles every live request. Mine looked like that. The audit table was created in migration 046. It had the right columns, an index, and a retention path.

It had held zero rows for its entire life. The only code that had ever touched it was a DELETE.

I found out because I tried to answer an ordinary question: of the 945 tools in my catalog, which ones actually work, and how slowly. Not a hard query. There was simply nothing to query. Pruning tools by usage was blocked on the same absence, and had been for months, silently, because the table looked healthy from every direction except the one that counts.

945 tools in the catalog, and not one row saying which ones ran

What shipped is a recorder in the engine’s MCP audit path. Every outbound tool call now writes one row: server, tool, status, elapsed milliseconds.

The row obeys the egress rule the rest of the system already uses. Arguments are stored as a per-install salted digest, never as text, so recurrence is visible and content is not. Error strings truncate to 300 characters. The write is best effort: a call that cannot be logged still runs, because an audit path that can fail a tool call is a worse bug than a missing row.

The first row in that table’s life:

mcp-monitor | get_cpu_info | ok | 1ms

A digest, not the arguments. That row is four years of schema finally holding a fact.

Instrumenting one client stack would have produced numbers that looked complete

This is the part that nearly shipped wrong. I have two MCP client implementations, for reasons that were good at the time: a pooled universal client used by the router, the worker and the CLI, and a spawn-based client used by the reasoning loader. They both make outbound calls. They share no code on the call path.

If I had instrumented the pooled one and stopped, every dashboard would have filled with plausible rows. Nothing would have said “half your traffic is missing.” Absence of a row is indistinguishable from absence of a call, which is exactly the failure that let an empty table survive since migration 046. So both stacks write. The spawn-based one resolves its log label lazily from the server id, because at construction time it does not yet know what it is talking to.

outbound calloutbound callPooled clientrouter, worker, CLISpawn clientreasoning loader; labelresolved lazilyRecordersalted digest, 300-charerrors, best effortOne row per callserver, tool, status, msInstrument one stack and the graph still looks full.
Both call paths, one recorder

The generic router was a door standing beside the locked one

My catalog exposes a “call any tool on any server” router. Routers are usually described as aggregation: one endpoint, one unified catalog, routing to the right backend. Mine lives in the full capability profile, which is the local default, on the reasoning that the local client is the owner and you do not take capability away from the owner on their own machine.

The word “owner” was doing work it could not support. The owner is a person. The profile was inherited by every MCP client that person happened to attach.

Memory writes in my system have a sanctioned path: a remember verb that posts into the gateway’s capture lane tagged with its source, so what lands is attributed and distilled. The recall server also exposes five verbs that reach the store directly: store, correct, reject, pin, unpin. Through the generic router, any attached client could call those five, and they arrived with no attribution, no capture-lane distillation, and no vault. The front door had a lock and the side door was a proxy.

The router now refuses those five by name and prints the supervised path instead. I considered pinning a vault onto router traffic and threw that away: the router proxies arbitrary third-party servers, and there is nothing in it that could honestly apply my confinement rules to someone else’s tool. A confinement that only pretends is worse than a refusal.

The same commit fixed something dumber. Any handler error was disconnecting the client mid-conversation. One tool throwing an exception ended the whole session. Tool-level failure had become transport-level failure, which is the specific reliability failure TheRouter documented in Claude Code v2.1.191: whether an agent fails closed on a recoverable error or takes the connection down with it.

Four clients pinned to a vault named demo, which does not exist

Then I looked at the attached clients. Four of them were pinned to a vault called demo. My vaults are portable, team-shared, Competitor intel and VODOU QA. There has never been a demo.

That is not a leak. An unknown vault resolves to an empty membership set, so those clients read nothing. It failed closed, which is the outcome everyone wants. It failed closed silently, which was the actual defect. The CLI printed demo as the vault. Settings → Clients in the console printed demo as the vault. A dead pin and a working pin rendered identically, so a person reading either surface would conclude four clients were confined to a scope that has no members and no existence.

The fix was one resolver in the engine that answers “which vaults exist” once, with every surface reading that single answer, the same shape the rate ceiling already uses. Two implementations of a disclosure boundary is two answers, and they eventually disagree.

The return type carries the interesting decision. It is an optional set, not a set. None means “could not tell,” which happens when the memory database is unreadable, and every surface renders that as nothing at all rather than as a warning. An empty set would have condemned every client on the first run without a memory database. An instrument with no evidence answers unknown, never broken.

Beforeclient 1: vault democlient 2: vault portableboth rendered the sameno vault named demo existsAfterclient 1: demo (missing)client 2: portableunreadable DB renders nothingone resolver, every surface

The property: a stored label that names a set is resolved against that set, in one place, with three answers

Stated as something you can check rather than something to be careful about:

Every persisted identifier that names a set (a vault, a scope, a tenant, a policy, a role) must be resolved against the live membership of that set before it is displayed, by exactly one function, returning three states: present, absent, and unknown. If any surface can render the raw stored string, that surface will eventually assert a confinement that stopped existing.

And the one that started this post: every table with a reader in production code has a writer in production code. This is either true or false of your repo and you can find out in about ninety seconds.

Run these three against your own gateway

Nothing here is mine. Point them at your own stack.

One. Find tables with readers and no writer.

for t in $(sqlite3 app.db "select name from sqlite_master where type='table' and name not like 'sqlite_%';"); do
  rows=$(sqlite3 app.db "select count(*) from \"$t\";")
  w=$(grep -rIiE "insert[[:space:]]+(or[[:space:]]+[a-z]+[[:space:]]+)?into[[:space:]]+\"?$t\b" --include='*.ts' --include='*.js' --include='*.py' --include='*.go' src/ | wc -l)
  r=$(grep -rIiE "from[[:space:]]+\"?$t\b" --include='*.ts' --include='*.js' --include='*.py' --include='*.go' src/ | wc -l)
  printf '%-30s rows=%-9s writers=%-4s readers=%s\n' "$t" "$rows" "$w" "$r"
done

Passing looks like writers>=1 everywhere readers>=1. Failing looks like mcp_session_calls rows=0 writers=0 readers=4: four places read it, nothing fills it, and every one of those readers is rendering an empty state as a healthy one. On Postgres, start with select relname, n_live_tup from pg_stat_user_tables order by n_live_tup; and grep the zeroes.

Two. Find labels that name nothing.

select c.name, c.vault
from clients c
left join vaults v on v.name = c.vault
where c.vault is not null and v.name is null;

Zero rows passes. Any row is a UI string asserting a boundary that resolves to nothing. Run the same join for tenant ids, policy names and role names.

Three. Make one tool throw and see if the session survives.

@server.call_tool()
async def call_tool(name, args):
    if name == "boom":
        raise RuntimeError("deliberate")
    return await dispatch(name, args)

From a real client (not curl), call boom, then call a working tool on the same connection. Passing: the second call returns a result and the first came back as a tool error. Failing: the second call gets a transport error or a reconnect, which means every third-party server you attach can end your agent’s session by having a bad day.

What the router and gateway write-ups leave out

The reference material on this is good and it stops one layer above the bug. Gateway architecture posts put audit in the control plane and say nothing about which code path emits the row, so a two-client-stack system passes the architecture review and logs half its traffic. Router posts cover aggregation, catalog unification and policy, but not capability inheritance: that a “call any tool” proxy hands its profile to every client that attaches, not to the person who configured it. AWS’s post on controlled tool orchestration is about enforcing the order of tool use, which is a real problem and a different one from enforcing who may reach a verb through a proxy. And fail-closed is universally treated as the good outcome. Nobody writes down that fail-closed must also be visible, which is how four clients sat pinned to a vault that did not exist.

Still live: session_id holds a server name, so calls do not group into a session

The column is called session_id and it currently carries the server name, because the session concept in my system never got a producer either. So I can now answer “which tools work and how slowly” per server and per tool, and I still cannot answer “show me the eleven calls this agent made during that one run.” The removal path deletes by both meanings, which works only because the two namespaces do not collide today. That is a coincidence, not a design.

The DEV write-up on MCP going stateless names where that belongs: long-lived continuity lives above the transport, in a layer the caller owns, not in the protocol’s session assumption. Agreed, and I have not built it yet. The other honest limit: arguments are a salted digest, so I can see that the same call recurred and never what it contained. Debugging a specific bad call still needs the caller’s own logs.