If you've wired an MCP server into an agent, you've probably done something like this:
{
"mcpServers": {
"billing": {
"command": "npx",
"args": ["billing-mcp"],
"env": { "BILLING_API_KEY": "sk-live-..." }
}
}
}
It works. It's also handing your live billing key to the least trustworthy process in the system.
The problem
An agent is a program that decides what to do at runtime based on text it was given — some of which comes from the outside world (a webpage it read, a document it summarized, a tool result). That's the whole point, and it's also why the agent process is the wrong place to keep a secret.
Two things go wrong with the config above:
The credential lives in the agent's environment. If the agent is compromised — prompt injection, a poisoned dependency, a tool that returns a malicious payload — the attacker is now one
os.environread away from your billing key. The blast radius of "the agent did something dumb" includes "the agent's keys are gone."The agent can call everything, and you can't prove what it did. The MCP server exposes a set of tools; the agent can call any of them. When something goes wrong, your evidence is scattered across logs that the agent itself could have influenced.
You can't fix this by making the agent more careful. The agent is the untrusted part. You fix it by moving the trust boundary.
The pattern: put a gateway between the agent and the MCP server
Instead of letting the agent talk to the MCP server directly, put a small trusted process — a gateway — in the middle:
agent ──► gateway ──► MCP server
(holds the (needs the
credential) credential)
- The credential lives in the gateway's environment, not the agent's.
- The agent gets a short-lived token scoped to its current run, and a URL that only reaches the MCP server through the gateway.
- On each tool call, the gateway injects the real credential just before forwarding upstream, and strips it from anything it hands back.
The agent is starved of credentials. It can make tool calls, but it never possesses the secret that authorizes them. Compromise the agent and you get a revocable, per-run token — not the billing key.
This is more than a reverse proxy, because the gateway is a policy decision point. Since every call goes through it, it can also:
- enforce which tools each agent may call (default-deny),
- enforce read-only access where you want it,
- and record every call and every refusal in one place the agent can't rewrite.
A worked example
Here's the pattern implemented with Agenthof, an open-source (Apache-2.0, Go) governance gateway. The config is the useful part; the tool is just one way to run it.
1. Register the MCP server with the gateway — this is where the secret lives:
tools:
ticket-search:
kind: mcp
url: https://tickets.internal/mcp
credential_source: static_env
token_env: TICKETS_MCP_TOKEN
TICKETS_MCP_TOKEN is read from the gateway's environment. The agent process never sees it.
2. Grant each agent only the tools it needs — default-deny:
name: legacy-triage
execution: fronted
endpoint: https://legacy.internal/agents/triage
tools:
- resource: ticket-search
mode: all # every tool ticket-search exposes
- resource: billing-mcp
tools: [get_invoice, list_invoices] # only these two of billing-mcp
An agent gets access to a resource only if it's named here, and to the tools it names (or an explicit mode: all). Leaving the list off isn't "allow everything" — it's rejected at config load. Widest access is always something you typed on purpose, never something you got by omission.
Want read-only? Say so, and the operator's classification decides what counts:
tools:
- resource: billing-mcp
mode: read-only # only the tools billing-mcp is declared to expose read-only
3. For OAuth-protected servers, even the client secret stays out of the agent. The gateway's broker mints a token from your identity provider per call:
tools:
billing-mcp:
kind: mcp
url: https://billing.internal/mcp
credential_source: static_env
grant_type: client_credentials
issuer: https://idp.example.com/
token_endpoint: https://idp.example.com/oauth2/token
client_id_env: BILLING_MCP_CLIENT_ID
client_secret_env: BILLING_MCP_CLIENT_SECRET
scope: billing.read
read_only_tools: [get_invoice, list_invoices]
The client id and secret are read from the gateway's environment; the agent gets the minted access token's effects, never the token, and never the secret that mints it.
4. Because everything flows through one point, every tool call and every refusal lands in a hash-chained, tamper-evident audit ledger — so "what did this agent actually do?" has a single, ordered answer.
What this does not do
Being honest about the boundary is the whole point, so:
- This governs and audits. It does not contain the agent. A compromised agent process still runs on some host; if that host has no sandbox, the process can still do local damage or reach the network directly. Keeping credentials out of the agent shrinks the blast radius — it is not a jail. Containment is your sandbox's job; this is the keys and the CCTV, not the locked room.
- The ledger is tamper-evident, not tamper-proof. Hash-chaining means edits are detectable after the fact. It does not stop an attacker with enough privilege from rewriting history — it makes the rewrite show up.
Those aren't weaknesses to paper over; they're where the boundary actually sits, and knowing that is how you deploy the pattern correctly (gateway on a trusted host, agent in a sandbox, ledger shipped somewhere append-only).
The takeaway
Regardless of which tool you use, the pattern is portable:
- Secrets live in a trusted gateway, never in the agent. The agent gets a scoped, revocable, per-run token.
- Default-deny tool access. An agent reaches a tool only because you named it, and read-only where read-only is enough.
- Log every call and refusal somewhere the agent can't edit.
The agent stays the flexible, fallible thing it's supposed to be — and it stops being the thing that holds your keys.
A working implementation of all of the above is at github.com/agenthof/agenthof. If you're doing this differently — a sidecar, a service mesh, provider-side scoping — I'd genuinely like to hear how it's holding up.
Top comments (4)
The "agent never gets the credential" part feels like the cleanest boundary here. Once the key is inside the agent process, every other control is kind of downstream from a compromised process. Having one trusted place that can enforce tool access and keep the audit trail also makes debugging a lot less painful.
Exactly — that's why the credential boundary is drawn at the process, not inside it. Once the key is in the agent's memory, a prompt-injected or just buggy agent already has it, and every control after that is cleanup on a compromised process. Putting it in one trusted place flips it: the agent asks, the gateway authorizes and injects, and the agent only ever holds a short-lived run token.
And the debugging/audit payoff is underrated, agreed. Because every call goes through that one place, "what did this agent actually reach, and on whose behalf?" is a ledger query instead of an archaeology dig. The credential is really just the first door — the same shape (authorize → inject → record, agent holds nothing) is what carries over to tools, exec, and sub-agents.
If you want to see that same shape across all four doors with the audit, there's a walkthrough here: dev.to/rojaneerdev/let-the-model-d...
Solid pattern. The credential half of agent trust gets most of the attention, and this handles it well. The other half we keep running into: even with keys safely out of the agent, the agent's claims about what happened stay unverified. We built bookingtruth, an MCP server that checks booking state against supplier-authoritative evidence and returns UNKNOWN instead of guessing when it cannot prove something - the "you can't prove what it did" problem applied to real-world state. Honest question, since you think about MCP trust: would an evidence gate like that earn a place in your stack, why or why not? Collecting objections as eagerly as praise.
Good distinction — I think we're pointing at the same principle from two ends. My line is "the agent's report of what it did isn't evidence; the door event is." Yours extends it past the door: even a truthful record of "the agent called book()" doesn't prove the booking is real in the supplier's system. Returning UNKNOWN instead of guessing is the right posture — same fail-closed honesty as recording a refusal rather than pretending something happened.
Where it fits: an evidence gate like that composes cleanly as a governed tool resource behind the tool door. The agent reaches it only through the gateway, the call and its verified/UNKNOWN result land on the ledger, and the audit then carries not just "what the agent asked for" but "what an authoritative source confirmed." That's strictly more than I can offer on my own — Agenthof vouches for what happened at the door, not for world-state beyond it.
The objection you asked for: it relocates the trust boundary, it doesn't remove it. You've swapped "trust the agent's claim" for "trust bookingtruth + the supplier's evidence API" — a good trade where a genuinely authoritative oracle exists (bookings, payments, inventory), and a weak one in domains where nothing is more authoritative than the actor itself. So the honest scope is "domains with an external source of truth," and the UNKNOWN path is what's doing the real work there. Within that scope, yes — I'd want it as a resource, not a replacement.