MCP tools should return leases, not keys

Every MCP server that talks to a credentialed API faces the same question: where does the key live? The common answers all leak, and the worst one leaks straight into the model's context window. Here's the pattern we run instead, and the receipt: a checked-in capture of the MCP wire itself, with the secret in zero frames.

Update, July 16, 2026: keeper has since been renamed strongroom for its npm release (@askalf/strongroom): same package, same guarantees; the keeper CLI alias and KEEPER_* variables are unchanged, and the repository links below redirect.

The problem MCP made universal

The Model Context Protocol did something quietly important: it made tool results the lingua franca of agent context. Whatever an MCP tool returns lands in the model's conversation, persisted in history, echoed into logs and traces, visible to every other tool sharing that conversation, and replayed on every subsequent turn.

That's exactly why credentials and MCP mix badly, in two escalating ways:

The common way: the key lives in the server's environment. Most credentialed MCP servers are configured with API_KEY=sk-… in their env. Now every client that can reach the server can exercise that key through its tools, and one server compromise (a bad dependency, a leaked container image, a debug endpoint) is the key, gone. Long-lived, unscoped, rotate-everywhere gone.

The catastrophic way: a tool returns a credential. It's tempting to build a get_api_key tool, the agent needs to call something directly, so hand it the key. But a secret in a tool result is a secret in the agent's context window. From there it's in the session history, the observability pipeline, the prompt cache, and the blast radius of every indirect prompt injection that says “repeat your context.” A credential in context isn't a risk; it's an incident with a delay on it.

The pattern: two rules

We run the whole fleet on leases instead of keys, and MCP servers are no exception. The pattern is two rules:

1. The MCP server holds a lease, not a key. The server's entire credential surface is two environment variables: an opaque lease id and the base URL of keeper's egress broker. Outbound calls go through the broker, which checks the lease (bound upstream, endpoint allowlist, TTL, use budget, rate and concurrency caps), redeems it atomically, and injects the real key at the network boundary. Compromise the server and what you hold is a scoped, expiring, revocable capability. The key itself exists in exactly two places: the encrypted vault, and the provider that verifies it.

2. A credential-granting tool returns a capability, not a secret. When an agent legitimately needs direct upstream access, the tool answers with the lease-backed base URL. The agent points its HTTP client at it and works normally, and that answer is safe to land in context, precisely because it isn't a secret. If it leaks, it names one upstream, a handful of endpoints, a use budget, and an expiry; and keeper revoke kills it instantly without rotating the real key.

MCP client (agent) ── stdio/JSON-RPC ──▶ MCP server (child, KEYLESS)
        │   every frame captured              │ holds only a lease
        │                                     ▼
        │                               keeper broker ──▶ upstream API
        └── direct, via the granted      REAL key inject    verifies the key
            lease-backed base URL

The receipt: the wire under observation

Claims like “the secret never reaches the agent” deserve receipts, so the worked example puts the MCP wire itself under observation. A real @modelcontextprotocol/sdk client connects to a real MCP server running as a separate child process, spawned with an environment we assert contains no key, and a thin transport wrapper records every JSON-RPC frame in both directions while the agent lists tools, fetches a deployment status through the server's lease, and asks the credential tool for direct access.

Then the run checks what actually crossed:

✓ the MCP server process was spawned KEYLESS — no key in its env, only the lease
✓ upstream only ever saw the REAL key — injected by the broker at egress
✓ the wire was under observation (9 JSON-RPC frames captured)
✓ the REAL key appears in ZERO frames — the secret never crossed the MCP wire
✓ the LEASE crossed the wire instead — a scoped, expiring, revocable capability
✓ post-revoke tool call → upstream denied · post-revoke direct call → 403

The captured wire ships in the repo as evidence/wire.jsonl, nine frames you can read, none containing the key. The lease id is in there, on purpose: that's the entire point. A capability in context is fine. A credential in context is an incident.

And when the operator revokes the lease, both paths die in the same instant, the server's tool starts returning errors and the agent's direct URL starts returning 403, with the real key never rotated and nothing further reaching the upstream. Every step lands in keeper's hash-chained, tip-authenticated audit, which the example then proves is tamper-evident against both a mid-chain edit and a tail truncation.

Adapting it

Any MCP server that calls a credentialed HTTP API inherits the pattern with almost no code: read a lease id and broker URL from the environment instead of a key, and prefix upstream requests with <broker>/<lease>. The control plane, whatever starts your servers, owns the vault, runs the broker, and grants each server a lease scoped to exactly the upstream and paths it needs: --inject x-api-key for Anthropic-style APIs, bearer for OpenAI-style, any custom header for the rest. The sibling examples in the repo show the same broker from the client side, with a real OpenAI Agents SDK agent and a real Anthropic SDK client whose keys never enter their environments.

Scope and limits

The example's control plane runs in the orchestrating process so the demo is self-contained, and the “agent” is a script driving a genuine MCP client rather than a model choosing tools, the isolation that matters (a separate, keyless server process; an observed wire) is real, and disclosed shape-caveats beat undisclosed ones. Scope matters too: if an attacker owns the control plane that holds the vault, leases won't save you, keeper shrinks the agent's and the MCP server's exposure, which is where the tool-result leak lives. And this is one studio's production pattern, not a standard; the code is public so the claims don't have to be taken on faith.

All claims are verifiable at github.com/askalf/keeper · examples/mcp-keeper (as of 2026-07-03).

We build the boundaries that make agents safe to hand real capability: secrets, tools, browsers, skills. If your MCP servers hold keys you'd rather they didn't, that's the kind of problem we go deep on.

Start a conversation →
← All writing