Governance proxy for MCP servers: allowlist, forced dry run, human confirmation, blast radius, audit
Copy the AI prompt to install this server into Claude Code, Cursor, or another agent — or use 1-click editor setup below.
One-click editor setup isn’t available for this listing yet — we don’t have a confirmed install command, and we’d rather show nothing than point your editor at the wrong package or host. Follow the project’s own setup instructions, linked above.
mcp-airlock is a proxy you put between an AI agent and an MCP server when the server can do
things you don't want an agent doing on its own. It speaks the 2026-07-28 revision of the
protocol (the stateless one: no session, no initialize, one POST per request) and adds
the parts the protocol leaves to you: who is allowed to call what, dry runs by default,
a human in the loop for dangerous calls, an audit trail and tracing.
It is deliberately small. There is no UI, no policy language beyond flat YAML, no MCP SDK of its own. The whole proxy is one Starlette app plus a few helper modules.
The agent sends a normal tools/call to the proxy instead of the server. The proxy:
Authorization: Bearer) or, if you run
it behind a gateway that already did the authentication, from an X-Airlock-Principal
header. It is never taken from the request body. No principal, no call.delete_service can be free in dev and gated
in prod.L0 (read) goes straight through.L1 (suggest) always goes through with dry_run: true, whatever the agent asked for.L2 (confirm) goes through with dry_run: true first, and the result comes back to the
agent as input_required with a description of what would happen and a signed
requestState. When a person says yes, the agent repeats the call with that state and
the proxy executes it for real, once. Repeating it again is refused.L3 (auto) goes through as sent.Refusals come back as tool results with isError: true, not as protocol errors, so the
model sees why and can do something else. Every result carries the verdict and the rule
that produced it in _meta.
The released version, no clone needed:
The same as a container. The image listens on 0.0.0.0:9000, runs as a non-root user and
writes audit.jsonl into /data:
From a checkout:
The demo starts a fake upstream with a handful of tools on port 9001 and the proxy on 9000,
walks through the interesting cases (refused tool, forced dry run, confirmation, replay,
blast radius, output cap, injection marking) and leaves the audit log and spans in
examples/.
The short version, recorded against that same fake upstream. An agent tries to delete a production service, gets a dry run and a confirmation prompt instead, the confirmation works exactly once, and a poisoned read result comes back flagged:

docs/make_demo_gif.py re-records it (uv run --with pillow python docs/make_demo_gif.py).
docs/clients.md shows how to point Claude Code and Cursor at the proxy and what the agent
sees when a call is refused or held for confirmation.
Against a real server:
There are ready-made policies for the GitHub, Grafana and Kubernetes MCP servers in
examples/policies/. They were written against the servers' source at a pinned commit,
so check them against your actual server before trusting them:
diff tells you which tools the server has that the policy doesn't mention, which policy
entries the server no longer has, and which L1/L2 tools have no dry_run argument.
Everything is environment variables. None are required for a single-process setup.
| Variable | What it does |
|---|---|
AIRLOCK_ENV | Environment name, picks the tier column in the policy. --env does the same. |
AIRLOCK_JWT_SECRET | Verify bearer tokens with HS256. sub becomes the principal, groups the groups. |
AIRLOCK_JWKS_URL, AIRLOCK_JWT_ISSUER, AIRLOCK_JWT_AUDIENCE | Verify bearer tokens against an OIDC provider (RS256/ES256). Takes precedence over the shared secret. Set the audience; without it any token from that provider is accepted. |
AIRLOCK_GROUPS_CLAIM | Claim to read groups from. Default groups. |
AIRLOCK_TRUST_PRINCIPAL_HEADER | Set to 1 to accept X-Airlock-Principal and X-Airlock-Groups. Off by default. Only turn it on behind a gateway that sets those headers itself and strips them from clients. |
AIRLOCK_SECRET | Key for signing confirmation tokens. Random per process if unset, which means a restart forgets pending confirmations. Set it if you run more than one replica. |
AIRLOCK_STORE_DSN | Postgres DSN for the shared state: used confirmation keys, approvals, blast-radius counters. Without it the state lives in process memory. |
AIRLOCK_AUDIT_DSN | Postgres DSN for the audit log, in addition to the JSONL file. |
AIRLOCK_APPROVAL_WEBHOOK | Slack-style incoming webhook, or a Telegram bot<token>/sendMessage URL. Confirmation prompts are posted there with an approve link. |
AIRLOCK_TELEGRAM_CHAT | Chat id for the Telegram case. |
AIRLOCK_PUBLIC_URL | Base URL for approve links. Default http://127.0.0.1:9000. |
AIRLOCK_UPSTREAM_AUTH | Value of the Authorization header sent to the upstream. This is the proxy's own credential; the caller's identity travels in _meta instead. |
A tier is resolved in this order: an entry for the exact principal, then the first matching
group in the order the token lists them, then tiers[environment]. The description is what
the person approving the call gets to read, so write it for them.
Rule ids you will see in _meta and the audit log: allowlist.deny, tier.unassigned,
tier.L0.read, tier.L1.dry_run, tier.L2.confirm, tier.L2.confirmed, tier.L2.dry_run,
tier.L3.auto, blast_radius.per_call, blast_radius.per_principal, dry_run.unsupported,
catalog.unavailable, principal.missing, protocol.<code>, mrtr.pending, mrtr.declined, mrtr.replay,
mrtr.expired, mrtr.mismatch, mrtr.bad_signature, mrtr.approved_oob, mrtr.upstream_input_required,
internal.error.
The confirmation token (requestState) is an HMAC-signed blob carrying the principal, the
tool, a hash of the arguments, the environment, the upstream URL, a random idempotency key
and an expiry (10 minutes). Nothing is stored when it is issued. When it comes back the
proxy checks the signature, checks that all of those still match the call in front of it,
re-runs the policy, burns the key, then charges the blast-radius counter. Burning is an
atomic insert in the store, so two replicas cannot both execute the same confirmation. A
decline burns the key too.
Before the prompt is issued the proxy asks the upstream for tools/list and looks at the
tool's schema. If the tool declares dry_run, the dry run is forwarded and its output is
included in the prompt. If it doesn't (most servers today), nothing is forwarded and the
person is asked to confirm without a preview. L1 on such a tool is refused, since there
is no safe way to run it. If the upstream cannot be asked at all, the call is refused with
catalog.unavailable rather than guessed at. The tools/list answer is cached for as long as
the upstream's ttlMs says, per principal; with ttlMs: 0 it is fetched on every gated call.
If the tool mirrors dry_run into an Mcp-Param-* header, the proxy rewrites that header
along with the body.
No reviews yet — be the first to share how this listing worked for you.
Showcase your server listing on GitHub or your project documentation. Embed this dynamic SVG badge to highlight official listing status and live engagement.
[](https://allmcps.com/mcp/mcp-airlock)<a href="https://allmcps.com/mcp/mcp-airlock"><img src="https://allmcps.com/api/badge/mcp-airlock?style=directory" alt="MCP Airlock on AllMCPs" /></a>