# Your MCP allowlist controls tool names, not what they return

> A read-only MCP profile still returned my cwd and MEMORY.md, because the disclosure was in the result envelope, not the tool list. How to check yours.

- Author: Chad Priest
- Published: 2026-08-28
- Canonical URL: https://blog.vodou.ai/mcp-allowlist-tool-response-envelope-leak/
- Tags: mcp, ai-agents, security, architecture, observability

---

If you run an MCP server that other people's clients attach to, you have probably written a profile: a list of tool names a given client is allowed to call. Mine had four entries and the word `read-only` next to it in the docs.

A profile is a claim about which functions run. It is not a claim about what comes back. Those are different sets, and I found out which one matters by asking a client under the most restrictive profile I had to list my available skills, and reading my own home address in the response.

Six commits over two days fixed it and then built the three things that were only possible once it was fixed: per-client identity, an audit log, and per-client rate limits. The interesting part is the two days, not the features.

## What a client actually gets now: an identity, a tool list, a trimmed envelope, a ceiling

Vodou can run as an MCP host, so Cursor or Claude Desktop or a script attaches and reaches the same memory and the same connected tools. Two transports: stdio, where the client spawns its own process and its scope rides on argv, and one loopback HTTP port serving every client at once. The HTTP path is where all of this went wrong, because multiplexing is what removed the per-process boundary stdio got for free.

**Diagram: One call from an attached client**

A tool call from an attached client passes through token lookup, rate window, tool allowlist, dispatch, and envelope reduction before the response returns

```text
  [Attached client] --> [Token to client row] --> [Sliding 60s window] --under ceiling--> [Tool allowlist]
  [Tool allowlist] --permitted--> [Dispatch]
  [Tool allowlist] --denied--> [Audit row]
  [Dispatch] --> [Envelope reduction (fixed)]
  [Envelope reduction (fixed)] --> [Audit row]

  notes:
    Attached client: its own bearer token
    Token to client row: profile + vault + ceiling
    Envelope reduction: MCP-spec keys only
    Audit row: salted digest, never arguments

  The allowlist gate is fourth. The envelope gate is fifth, and it was missing.
```

## The tool whose name sounded like recall answered with an inventory of my servers

The read-only profile shipped with four tools. One of them was `vc_intelligent_query`, on the list because its name sounded like memory retrieval. It was a hello-world stub. Asked "what is my dog's name?", it returned a status banner listing every connected MCP server by name.

That is exactly the disclosure I had excluded `vc_server_status` to prevent. I had removed the tool that says "here is your topology" and kept a tool that says the same thing while being named after recall. I curated the profile by reading tool names, which is the same thing as curating it by reading marketing copy.

Dropping it from the profile took one line. The lesson took longer: I could not tell you, at that moment, what any of the remaining three actually returned, because I had never looked at a raw response body. I had looked at the list.

## 47KB with no cwd, 73KB with it, and neither number was about the tool list

So I looked. Every tool result from our dispatcher had a `context` envelope appended to it: absolute working directory, git branch, memory hits with no vault filter applied, and a workspace summary of about 24KB that carried MEMORY.md verbatim.

Under the restricted profile, a plain skills lookup returned my home address and the directory I was standing in. Not through a tool anyone would classify as sensitive. Through the safest tool on the list, in a field the profile had never heard of.

Withholding a tool is worth nothing while a permitted one carries the same data out in the envelope. The fix reduces `tools/call` results to the MCP-spec keys for every profile except the fully trusted one, and it is an allowlist over fields, not a denylist: the next field somebody appends to a result is excluded by default, because the cost of forgetting is silent disclosure rather than a broken build.

**Diagram: Same skills lookup, two profiles**

Restricted profile response is 47KB with no cwd or MEMORY.md; full profile is 73KB with both

```text
  restricted profile        : 47 KB  ##################### (fixed)
  full profile (own editor) : 73 KB  ################################
```

The 26KB difference is the envelope. It was leaving under both profiles before, and the tool allowlist was identical in both cases.

## Everything I had verified was curl, and curl never holds a session

The reason none of this surfaced earlier is that every test of MCP host mode was `curl` against a server I had started myself in the next terminal.

curl never sends `initialize`. It never holds a connection open across calls. It never interleaves two clients. It never lives long enough to hit a process-level timer. Which is how a 90-second watchdog had been killing stdio host mode since the day that mode shipped, without a single test noticing.

The replacement is `scripts/dogfood-mcp-host.py`: full handshake, a session held open across calls, two clients at once on one port, and a mode that attaches a real editor process and asks it something only memory can answer. That script is what found the envelope. curl had been returning 200s over it for weeks.

## The invariant: every field in a response is on the profile's allowlist, or it is not in the response

Stated so you can check it rather than agree with it:

**For every trust level below "this is me on my own machine", the set of keys in a tool result is a subset of the protocol's own result schema, and every addition to that set is an explicit, reviewed entry.** True or false of your codebase, and you can go and look.

There is a second one, which is what the last three commits are about. **A limit, a revocation and an audit row all require a subject, and a shared credential has no subject.** Before per-client tokens, every attached client presented the same bearer. So an audit row could only ever have said "something read your memory", revoking one client meant rotating a secret that all of them held, and throttling "the client" meant throttling all of them at once. Identity is not a nice-to-have on top of egress control; it is the thing the other three controls bind to. `migrations/080_mcp_clients.sql` is a table with a token digest, a profile and a vault per row, and everything after it was cheap.

**Diagram (beforeafter)**

Before: one shared token, one profile for all clients, audit impossible. After: a row per client with its own token digest, profile, vault and rate ceiling

```text
  BEFORE: One shared bearer
    - every client sends the same token
    - one profile and one vault for all of them
    - revoke = rotate the secret everyone holds
    - the log can only say "something read it"
    - one runaway loop is everyone's latency

  AFTER: A row per client
    - token minted per client, only the digest stored
    - its own profile and vault
    - revoke one, the rest keep working
    - the log names who, and denials are rows too
    - its own ceiling in a sliding 60s window
```

## Five minutes: pipe one `tools/call` into your own server and count the result keys

Nothing here is ours. Point it at whatever MCP server you run.

```bash
SERVER="node ./dist/my-mcp-server.js"     # or: python -m my_server, or a binary
TOOL="the_most_boring_read_only_tool_you_have"

printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"$TOOL\",\"arguments\":{}}}" \
 | $SERVER 2>/dev/null | jq -c 'select(.id==2) | .result | keys'
```

Passing looks like `["content"]`, or `["content","isError"]`. Anything else is a field your profile does not know exists. Then ask whether that field is about you:

```bash
# same pipeline, but grep the whole result for facts about this machine
... | jq -r 'select(.id==2) | .result | tostring' \
    | grep -Eoi "$HOME|$(git branch --show-current)|$(hostname)|$(whoami)" \
    | sort -u
```

Passing is no output. Any line means a low-authority tool call is a machine-fingerprinting call, and the size tells you the rest: append `| wc -c` and compare your restricted profile against your trusted one. If those two numbers are equal, your profile is a filter on names only.

Last one, thirty seconds, and it decides whether your audit log can ever be useful:

```bash
grep -rn "Bearer\|AUTH_TOKEN\|authorization" src/ | head -20
```

If the expected token resolves to a single env var or a single file for every caller, then your server has one subject. Revoke, throttle and "who read this" are the same unanswerable question, and no amount of logging fixes it, because the log has nobody to name.

## Every MCP gateway I read inspects the request on its way out

The published work on this is real and it is pointed the other way. Google's [Agent Gateway egress mode](https://codelabs.developers.google.com/agw-cuj-arun-egress-emcp) proxies outbound requests from agents and decodes MCP tool calls for policy inspection. [`mcp-egress-guard`](https://pypi.org/project/mcp-egress-guard/) sits between the agent and the downstream server and intercepts every `tools/call` before execution. The [governed execution gateway](https://dev.to/gde/the-governed-execution-gateway-securing-mcp-servers-and-tool-egress-proxies-1ee3) writeup names unthrottled loops and runaway recursive calls as a first-class risk, which is exactly why the rate limit exists.

All three inspect the call going out. Almost nothing inspects the body coming back, because the server returning it is your own and therefore assumed trusted. That assumption is fine right up until your own server is the one attaching a workspace summary to every response, and the untrusted party is the client reading it. [Tidegate](https://github.com/cristoslc/tidegate) is the honest one here: it says plainly that MCP gateways scan tool-call payloads and still cannot see every path data takes out.

Worth noting that [Google's stateless MCP work](https://developers.googleblog.com/scaling-ai-agent-infrastructure-with-the-mcp-stateless-updates/), published the same day I was fixing this, pushes the whole ecosystem toward exactly the shape that caused it: away from per-session, per-process state and toward multiplexed servers handling many concurrent callers. That is the right call for scale. It also means the free identity boundary that stdio gave you is going away by default, and something has to replace it deliberately.

## Still open: on stdio a client id is a label, so the ceiling is a suggestion

Rate limits are enforced on the HTTP port only. On stdio, the client id is self-declared on the command line: it is a name, not a credential. A limit keyed on it enforces nothing, because the client can respawn with a fresh id and an empty window. Same asymmetry as revocation, where the stdio answer is to uninstall the entry rather than cut the client off.

And the ceiling bounds a shared resource rather than removing it. One HTTP port still serves every attached client through one server behind one lock, so from the other clients' seats, a heavy caller's requests are still requests they queue behind. `migrations/082_mcp_client_rate_limit.sql` caps how long that queue can get. It does not make the queue stop being one queue.

---

Source: [Your MCP allowlist controls tool names, not what they return](https://blog.vodou.ai/mcp-allowlist-tool-response-envelope-leak/) by Chad Priest, from Building Vodou in Public.
