Stateless governance proxy for MCP (2026-07-28): policy, dry-run, human confirmation, audit, tracing
mcp-airlock
以下摘录自该仓库的 README(片段) · 在 GitHub 读完整 README →
Краткий обзор по-русски
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. A name that is
blank or has whitespace around it is no principal either, from either source: trimmed,
alice would be alice.
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.
The JSONL file gets each one before the proxy goes on; the Postgres table, when there is
one, is written from a queue a moment later.
Refusals come back as tool results with isError: true, not as protocol errors, so the
model sees why and can do something else. Every tools/call result, a pending one included,
carries the verdict and the rule that produced it in _meta. The io.mcp-airlock/ keys there
are the proxy's alone: any the upstream puts into a result, one of its content blocks, a
tools/list answer or a tool in it are removed before the proxy adds its own, and any the
client sends are removed before the call is forwarded.
The proxy accepts three methods: tools/call as above, tools/list and server/discover.
A tools/list answer is cut down to the tools the policy lists for the caller (the number of
hidden tools is in _meta["io.mcp-airlock/hidden_tools"]), then checked against the
pins and scanned for injection phrases. Any other method is
refused with METHOD_NOT_FOUND.
Protocol-level answers
* **Missing principal**: HTTP 401 / -32011 (PRINCIPAL_REQUIRED) when there is no JWT and no
trusted X-Airlock-Principal header.
* **Unproxied method**: HTTP 404 / -32601 (METHOD_NOT_FOUND) for anything but tools/call,
tools/list and server/discover. A legacy initialize has no _meta envelope and gets
HTTP 400 / -32602 instead.
* **Order**: protocol rejections (-32700, -32600, -32602, -32020, all HTTP 400, and
-32601/404) are answered before the missing-principal 401, so an unauthenticated client can
tell a malformed request from a refused one. These cover the 2026-07-28 _meta envelope and
the Mcp-Method/Mcp-Name header checks.
* **Forwarded requestState**: one without the al1. prefix is forwarded as upstream state, so
a token that lost its al1. prefix is treated as a new call.
Installing
From PyPI, as a tool or a package:
Three features are extras, so the base install stays small:
| Extra | Brings | Needed for |
|---|---|---|
| postgres | psycopg, psycopg-pool | AIRLOCK_STORE_DSN, AIRLOCK_AUDIT_DSN, airlock-audit query --dsn |
| otlp | the OTLP HTTP span exporter | OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
| regex | google-re2 | a where rule with regex |
A DSN without the postgres extra stops the proxy at startup with that hint. An OTLP