Stateless governance proxy for MCP (2026-07-28): policy, dry-run, human confirmation, audit, tracing
- ✓Open-source license (MIT)
- ✓Actively maintained (<30d)
- ✓Clear description
- ✓Documented (README)
claude mcp add mcp-airlock -- uvx mcp-airlock{
"mcpServers": {
"mcp-airlock": {
"command": "uvx",
"args": ["mcp-airlock"]
}
}
}MCP Servers overview
# mcp-airlock
[Русская версия](README.ru.md)
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.
## How a call goes through
The agent sends a normal `tools/call` to the proxy instead of the server. The proxy:
1. Works out who is calling. That comes from a JWT (`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.
2. Looks the tool up in the policy. Tools that are not listed are refused. Listed tools have
a risk tier per environment, so the same `delete_service` can be free in `dev` and gated
in `prod`.
3. Depending on the tier:
* `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.
4. Checks the blast radius: how many objects one call touches (the length of a list
argument you name in the policy) and how many a principal has touched in the last hour
or day.
5. Forwards the call, cuts the response down to the output cap if it is too big, and marks
anything in it that smells like a prompt injection. Marking only; it does not change what
the agent gets to see.
6. Writes two audit records, one before the upstream call and one after, whatever happened.
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`.
## Running it
The released version, no clone needed:
```
uvx mcp-airlock --policy policy.yaml --upstream http://127.0.0.1:8080/mcp --env prod
```
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`:
```
docker run --rm -p 9000:9000 -v $PWD/policy.yaml:/data/policy.yaml \
ghcr.io/shalimov04/mcp-airlock:0.1 --policy policy.yaml --upstream http://host.docker.internal:8080/mcp --env prod
```
From a checkout:
```
uv sync
uv run pytest
uv run python demo.py
```
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:
```
uv run mcp-airlock --policy policy.example.yaml --env prod \
--upstream http://127.0.0.1:9001/mcp --audit audit.jsonl
```
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:
```
uv run airlock-policy lint examples/policies/github.yaml
uv run airlock-policy diff examples/policies/github.yaml --upstream http://127.0.0.1:8080/mcp --env prod
```
`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.
### Configuration
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. |
## The policy file
```yaml
version: 1
environment: prod
output: { max_chars: 16000, chars_per_token: 4 }
blast_radius: { max_per_call: 50, max_per_principal: 500, window_s: 3600 }
tools:
get_service:
tiers: { dev: L0, staging: L0, prod: L0 }
output: { max_chars: 5000 }
set_replicas:
description: scale services up/down (reversible)
tiers: { dev: L3, staging: L1, prod: L2 }
principals:
"group:oncall": { prod: L3 } # on-call people skip the confirmation in prod
count_arg: names # objects per call = len(arguments.names)
blast_radius: { max_per_call: 3, max_per_principal: 5, window_s: 3600 }
delete_service:
description: permanently delete a service (irreversible)
tiers: { dev: L2, prod: L2 } # nothing for staging, so it is refused there
```
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`, `internal.error`.
## Confirmations in detail
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.
If an approval webhook is configured, the same prompt goes to Slack or Telegram with a
link. The link carries a second token signed with a different key, so the agent, which
only ever sees `requestState`, cannot approve its own call. Opening the link shows a page
with a button; the `GET` does nothing (link previews and prefetchers would otherwise
approve things), the `POST` records the approval. The agent finds out by repeating the call
with `requestState` and no `inputResponses`: it gets `input_required` back with
`status: pending` until the button is pressed, then the call runs.
The approve page is a capability URL. Anyone holding it can press the button. Put
`/approve` behindWhat people ask about mcp-airlock
What is Shalimov04/mcp-airlock?
+
Shalimov04/mcp-airlock is mcp servers for the Claude AI ecosystem. Stateless governance proxy for MCP (2026-07-28): policy, dry-run, human confirmation, audit, tracing It has 1 GitHub stars and its last recorded update is dated 2026-09-14.
How do I install mcp-airlock?
+
You can install mcp-airlock by cloning the repository (https://github.com/Shalimov04/mcp-airlock) or following the README instructions on GitHub. ClaudeWave also provides quick install blocks on this page.
Is Shalimov04/mcp-airlock safe to use?
+
Our security agent has analyzed Shalimov04/mcp-airlock and assigned a Trust Score of 87/100 (tier: Trusted). See the full breakdown of passed checks and flags on this page.
Who maintains Shalimov04/mcp-airlock?
+
Shalimov04/mcp-airlock is maintained by Shalimov04. The last recorded GitHub activity is dated 2026-09-14, with 0 open issues.
Are there alternatives to mcp-airlock?
+
Yes. On ClaudeWave you can browse similar mcp servers at /categories/mcp, sorted by popularity or recent activity.
Deploy mcp-airlock to your cloud
Ship this repo to production in minutes. Each platform spins up its own environment with editable env vars.
Maintain this repo? Add a badge to your README
Drop the badge into your GitHub README to show it's tracked on ClaudeWave. Each badge links back to this page and reflects the live Trust Score.
[](https://claudewave.com/repo/shalimov04-mcp-airlock)<a href="https://claudewave.com/repo/shalimov04-mcp-airlock"><img src="https://claudewave.com/api/badge/shalimov04-mcp-airlock" alt="Featured on ClaudeWave: Shalimov04/mcp-airlock" width="320" height="64" /></a>More MCP Servers
Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.
User-friendly AI Interface (Supports Ollama, OpenAI API, ...)
An open-source AI agent that brings the power of Gemini directly into your terminal.
Real-time global intelligence dashboard. AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface
🕷️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl! Don't be shy, join here: https://discord.gg/EMgGbDceNQ
The fastest path to AI-powered full stack observability, even for lean teams.