Architecture¶
Arbiter is an MCP tool-call firewall that sits between AI agents and MCP servers. Every request an agent makes passes through Arbiter before it reaches the upstream server. Every response passes back through on the way out.
Two processes run side by side:
A proxy on port 8080 that handles MCP traffic through a 9-stage middleware chain
An admin API on port 3000 that handles agent registration, delegation, token issuance, and session management
┌──────────┐ ┌─────────────────────────────────────────────┐ ┌────────────┐
│ Agent │────>│ Arbiter │────>│ MCP Server │
│ Client │<────│ (proxy :8080 admin API :3000) │<────│ (upstream) │
└──────────┘ └─────────────────────────────────────────────┘ └────────────┘
│
v
┌───────────────┐
│ Keycloak / │
│ IdP (OIDC) │
└───────────────┘
The Middleware Chain¶
Every proxied request passes through nine logical stages, in order. Any stage can reject a request — when that happens, the response goes straight back to the client and downstream stages are skipped. Audit and metrics are still recorded regardless.
The implementation has additional sub-stages between the nine listed here (enforcement gates, credential injection, session lifecycle checks). These are internal to the proxy handler and don’t change the logical model, but if you’re reading the source you’ll see them as intermediate stages in handler.rs.
Request ──> Tracing ──> Metrics ──> Audit ──> OAuth ──> MCP Parse
│
<── Forward Upstream <── Behavior <── Policy <── Session
Here’s what each stage does:
# |
Stage |
What It Does |
|---|---|---|
1 |
Tracing |
Assigns a span and structured log entry to the request |
2 |
Metrics |
Records Prometheus counters and starts the latency timer |
3 |
Audit |
Begins capturing the audit entry (timestamp, request ID) |
4 |
OAuth |
Validates the JWT bearer token against cached JWKS keys, injects claims |
5 |
MCP Parse |
Parses the JSON-RPC body, extracts tool name, arguments, resource URI |
6 |
Session |
Validates the session is active, tool is whitelisted, budget isn’t blown |
7 |
Policy |
Evaluates authorization rules; deny-by-default, specificity wins |
8 |
Behavior |
Flags when operation types diverge from session scope |
9 |
Forward |
Terminal handler: proxies to upstream, then runs credential scrubbing, audit finalization, and metrics on the response before returning it |
The ordering matters. OAuth runs before session validation because you need to know who is making the request before you can check whether they’re allowed. Policy runs after session validation because the session provides the declared intent that policies match against.
A Request’s Journey¶
Walk through what happens when an agent calls query_transactions:
The agent sends an HTTP POST to Arbiter’s proxy port (8080) with a JSON-RPC body, a JWT in the Authorization header, and a session ID in
x-arbiter-session.Tracing tags the request with a unique span for structured logging.
Metrics increments
requests_totaland starts a duration timer.Audit timestamps the request and generates a UUID request ID.
OAuth validates the JWT signature against the cached JWKS for the issuer, checks expiry and audience, and injects the parsed claims (subject, groups) into the request context.
MCP Parse deserializes the JSON-RPC body. For a
tools/callmethod, it extracts the tool name (query_transactions) and the arguments ({"account": "ACC-2847", "period": "2025-Q4"}).Session looks up the session by ID, confirms it belongs to this agent, checks that
query_transactionsis on the tool whitelist, that the call budget hasn’t been exceeded, and that the rate limit window hasn’t been blown.Policy evaluates loaded policies against the request context: agent identity, trust level, declared intent, tool name, and arguments. If no Allow policy matches, the request is denied.
Behavior classifies
query_transactionsas a read operation and compares it to the session’s declared intent. A read intent plus a read operation, so no anomaly.Forward proxies the request to the upstream MCP server. On the response path back through this stage:
Credential scrubbing checks whether any credentials that Arbiter injected into the outgoing request appear in the upstream response. If they do (in any encoding: plaintext, URL-encoded, JSON-escaped, hex, base64, base64url, double-URL-encoded, or Unicode JSON-escaped), they’re replaced with
[CREDENTIAL]before the agent sees them.Audit finalizes the entry with upstream status code, latency, and any credential scrubbing actions, then writes the JSONL record.
Metrics records the request duration histogram and tool call counter.
The response goes back to the agent.
Crate Architecture¶
Arbiter is built as a Rust workspace with 14 crates. Each crate owns one domain:
Crate |
Domain |
|---|---|
|
Integration binary that wires everything together |
|
Async HTTP reverse proxy with middleware chain |
|
OAuth 2.1 JWT validation, JWKS caching |
|
Agent model, trust levels, delegation chains |
|
Admin REST API (axum) |
|
MCP JSON-RPC parser |
|
Deny-by-default policy engine |
|
Task session management |
|
Drift detection |
|
Structured JSONL audit logging with redaction |
|
Prometheus metrics |
|
Credential injection and response scrubbing |
|
Storage abstraction (in-memory + SQLite) |
|
CLI tool ( |
You don’t need to know the crate structure to use Arbiter. It ships as a single binary. But if you’re reading the source, the boundaries are clean: each crate has a focused responsibility and a well-defined interface.
Request and Response Handling¶
Arbiter processes traffic in both directions:
Inbound (requests): The MCP parser extracts tool names and arguments. The policy engine evaluates parameter constraints. Audit redaction strips sensitive fields before logging.
Pre-forward (credential injection): After all authorization checks pass, credential references (${CRED:ref}) in the request body and headers are resolved and substituted with real values just before the request is forwarded upstream. The agent never sees the resolved credentials. This happens after policy and behavior checks, ensuring that only authorized requests receive credential injection.
Outbound (responses): When credential injection is active, Arbiter scrubs the response body for the exact secrets it injected. Scrubbing covers multiple encodings (plaintext, URL-encoded, JSON-escaped, hex, base64, base64url, double-URL-encoded, Unicode JSON-escaped) to catch credentials even if the upstream transforms them. Matches are replaced with [CREDENTIAL]. This is a closed-scope defense: Arbiter scrubs what it injected, not arbitrary patterns.
Key Design Decisions¶
Four architectural decisions shaped Arbiter’s design. Each is documented as a formal Architecture Decision Record:
Architecture Decision Records: deny-by-default authorization, Rust as the implementation language, in-memory registry as the default storage, TOML as the policy language
Next Steps¶
Security Model: the threat model and defense philosophy
Quickstart: get it running
Policy Language: write authorization rules