Agent Identity, AutheNtication & AuthoriZation — a small, self-contained agent-security stack for MCP, written from scratch in Rust.
aINZ credentials an agent, decides what it may do, and enforces that decision at the one place that can actually see a tool call — the MCP boundary. It borrows ideas from Ory's Hydra, Keto and Oathkeeper (opaque tokens resolved by central introspection, relationship-based permissions, and enforcement at the boundary) and implements a deliberately minimal subset — roughly a thousand lines of Rust — tuned for one job: securing an agent's access to an MCP server. It is not a clone or a repackaging of those services. Everything runs in-process on a single offline machine: no auth server, no network hops between trusted components, nothing that phones home.
It answers three questions, each owned by exactly one piece:
| Question | Owner | Idea borrowed from |
|---|---|---|
| Who is this agent? — issue + resolve a keycard | mint + verify::introspect |
Hydra |
| May it do this? — per-tool decision over a rule graph | verify::check |
Keto (Zanzibar) |
| Enforced where? — block the call before the tool runs | the MCP server itself — the enforcement point (PEP) | Oathkeeper |
When you stand up an agent, you first decide the exact set of MCP tools it is allowed to call — its
approved surface. You hand that list to identity.register(), which in one atomic step issues
the agent a keycard (a random, meaningless token — think of a hotel key card) and records its
rules (which tools it may invoke). The keycard has no power of its own; it is just a lookup
handle, and all the real authority lives in aINZ's registry — so revoking an agent is a single
DELETE. From then on, every time the agent tries to call a tool, the MCP server checks the keycard:
is it still valid (verify::introspect), and does a rule allow this agent to call this tool
(verify::check)? It then allows the call or denies it.
- Genuine per-tool authorization middleware — every real tool call is a live authz decision, not
a static capability list (verified absent from the official Rust MCP SDK and from common reference
servers). Discovery is fully decoupled from it:
tools/listshows every tool's name + one-line summary up front anddescribe_toolsreturns full schemas on demand, but invoking anything still runs the live check. - Service-scoped grants — a keycard's rules are qualified by
service_id, so the same bare tool name (e.g.secret_read) resolves independently across two genuinely separate MCP deployments (mcp-one/mcp-two) with no collisions.
Requirements: Rust (cargo), the sqlite3 CLI, and — for the console — Node/npm. jq is
optional (nicer demo output).
# 1. build the decision engine + the MCP server
cargo build --manifest-path libs/Cargo.toml --features dev # ainz lib + ainz-identity CLI
cargo build --manifest-path mcp-one/Cargo.toml # the MCP server
# 2. create the local store (idempotent — safe to re-run)
libs/scripts/init-db.sh # → libs/ainz.db
# 3. see it work
./libs/scripts/identity-demo.sh # issuance: one atomic register
./mcp-one/scripts/mcp-demo.sh # tool discovery + describe_tools over stdio
./mcp-one/scripts/authz-demo.sh # grant & deny, proven end-to-end
# 4. the console (optional, interactive)
cd frontend && npm install && npm run dev # → http://localhost:5190authz-demo.sh is the one to watch: it registers a docs-only agent, then drives the real server with
that keycard — doc_read → ALLOW, deploy_run → DENY, bogus token → DENY — the same
tool and server, decided entirely by verify::introspect + verify::check:
$ ./mcp-one/scripts/authz-demo.sh
▶ register 'sess-authz-…' with DOCS-ONLY perms (no deploy tools):
token: mlk_ba430b8e4904… (granted: doc_* only)
── valid keycard (docs-only) ─────────────────────────────────
doc_read (granted) → {"isError":false,"text":{"authorized_agent":"sess-authz-…","tool":"doc_read","ok":true}}
deploy_run (ungranted)→ {"isError":true, "text":"authorization denied: sess-authz-… may not invoke deploy_run"}
audit:
{"tool":"doc_read", "agent":"sess-authz-…","decision":"allow","reason":"granted"}
{"tool":"deploy_run","agent":null, "decision":"deny", "reason":"sess-authz-… may not invoke deploy_run"}
── bogus keycard ─────────────────────────────────────────────
doc_read (bad token) → {"isError":true,"text":"authorization denied: inactive or unknown keycard"}
✔ same tool, same server — allow vs deny decided by verify::introspect + verify::check.An interactive web UI (SvelteKit, port 5190) for testing MCP authorization. Think MCP Inspector — except the sidebar mints a real aINZ keycard, and every panel exercises the actual binaries over stdio. No mocks, no fixtures.
The sidebar — mint an identity. Set the human principal (sub) and a TTL, tick the tool surface
(everything except secret_read, a permanently-denied tool, is checked by default), and hit
+ New Agent Identity. That shells out to ainz-identity register --json and hands back a real
keycard — the token is shown once. Revoke tears the agent down (card and rules) atomically.
Four tabs, each a different vantage point on that live agent:
| Tab | Shows | Backed by |
|---|---|---|
| Request | live preview of the exact registration request as you edit the fields | GET /api/identity |
| Identity | the minted keycard — token, agent id, issued / expires | POST /api/identity → ainz-identity register |
| Permissions | the agent's relation_tuples (the doors it holds) next to the known tools it has no rule for |
sqlite3 read of the verify graph |
| MCP Tools | what the agent sees on connect — the meta discovery tools (list_tools / describe_tools / ping / whoami) — plus a live tools/call query |
ainz-mcp over stdio |
Tool query. Fire a real tools/call at ainz-mcp using the live keycard — name a tool, add JSON
args, and optionally a claimed service_id. Allow/deny reflects that agent's actual grants
(verify::introspect + verify::check). With no identity minted, the call runs in dev mode
(unauthenticated, everything allowed).
Five top-level directories:
ainz/
├── libs/ the ainz Rust crate — the decision engine
│ ├── src/ model · token · storage (core) · mint · verify · identity
│ ├── migrations/ the SQLite schema (agent_registry, relation_tuples)
│ └── scripts/ init-db.sh, identity-demo.sh
├── mcp-one/ an MCP server (rmcp) — the PEP; tool discovery + authz middleware
│ └── scripts/ mcp-demo.sh (discovery), authz-demo.sh (grant & deny)
├── mcp-two/ a second, independent MCP server — proves service-scoped authz
├── frontend/ the Console (SvelteKit) — drives the real binaries, no mocks
└── docs/ full design docs (mkdocs-material) + mkdocs.yml at the root
The libs/ crate is a single package today (ainz) exposing six modules across a strict dependency
DAG — core (model · token · storage) → verify / mint → identity. Each owns exactly one duty;
identity is a thin transaction boundary that composes the other two. When those modules earn their
own crates, libs/ becomes a Cargo workspace.
Built & tested
identity/mint/verify— issuance, the rule graph, and the authorization decision (read + write)verify::check(Zanzibar DFS with inheritance) +verify::introspect(keycard → identity)- The MCP server: tool discovery (
list_tools/describe_tools) and the enforcement middleware (grants & denies) - The Console driving all of it against the real binaries
- 19 lib unit tests + six demo scripts
Chosen but not yet wired
- Encrypted SQLite (SQLCipher) — the prototype uses plain SQLite today
- Bearer identity over stdio rides an env var (
AINZ_TOKEN) — a prototype simplification
Deferred until a use case demands it
gate— a standalone body-inspecting proxy PEP. Only needed for an MCP server you don't control (can't inject middleware there); until then the server enforces itself.- Macaroon-based delegation — for a credential that must cross a trust boundary you don't control and be verified with no live authority present. Not the per-agent flow.
This is the decision layer — identity, authn, authz — and it stops at the MCP boundary. What process presents the token, and whether it could reach the MCP by some other route, is out of scope (that's a sandboxing concern). aINZ decides and enforces at the point a tool call is actually visible; it doesn't claim to be the only door to the data.
Full design writeup — architecture, the tuple model, per-component deep dives, a from-scratch glossary
(macaroon, HMAC, ReBAC, Zanzibar, …), and the design decisions behind every trade-off — lives in
docs/:
mkdocs serve # → http://localhost:8899 (requires mkdocs-material)Prototype — rev1. Licensed under Apache 2.0.
