How the gateway works
What happens to one tool call between your agent and your MCP server — step by step, with the two caches, the off-path log writer, and the guarantees each one buys.
One request, start to finish
Every proxied call hits /proxy/<server>/<path> (any HTTP method) and walks the same seven steps:
| # | Step | What happens |
|---|---|---|
| 1 | Authenticate | The Authorization: Bearer agent JWT is extracted and signature-verified. Missing or invalid → 401, before anything else runs. |
| 2 | Revocation check | The exact token is looked up by its SHA-256 hash against revoked-token records. Rotated or decommissioned tokens → 401. Cached ~5 s, so a just-revoked token survives at most that window. |
| 3 | Resolve identity + upstream | One query joins the agent row (name, org, status, environment, allowed/denied servers) with the org's registered upstream for <server>. Unknown agent → 401; unregistered server → 404. Cached ~5 s; only positive lookups are cached, so a just-registered server resolves immediately. |
| 4 | Extract the tool | MCP JSON-RPC tools/call bodies yield the tool name and arguments from params. Other JSON-RPC methods log the method name; non-JSON traffic logs the path — every call stays attributable. |
| 5 | Policy verdict | The layer-2 engine evaluates server scope, agent status, and tool rules into permitted / denied, a reason, and a blast radius. An engine error in observe mode fails open: the call passes, a warning is logged. |
| 6 | Observe: record and forward | The verdict is recorded — a denied decision sets would_block=true — and the call is forwarded regardless. Observe mode never blocks. |
| 7 | Stream the response | The upstream response streams back byte-identical, chunk by chunk — SSE is never buffered. Only hop-by-hop headers (RFC 9110) are stripped in each direction. |
Authorization header is not hop-by-hop, so it is forwarded to the upstream today. See honest limits.The call-log write path
Logging is deliberately off the response path. When a call completes, the gateway builds the log row (id and timestamp included), publishes it to the live feed immediately, and enqueues it; a single writer task drains the queue into Postgres in batches. Your call never waits on a database round trip — per-call INSERTs interleaving into request timings is exactly what blows a latency tail.
Fail-open extends to observability itself: a full queue or a failed batch write is logged as an error and the rows are dropped — a logging failure never fails a customer call. The trade is explicit: under write failure you lose log rows, not traffic.
would_block semantics
In observe mode a denied verdict does not block — it stamps the log row with would_block=true, the violated rule's reason, and the blast radius, then forwards the call. Accumulated over real traffic, these rows answer the only question that matters before enforcement: "what would my policy have blocked?" When layer 4 flips a rule to enforce, the identical verdict starts returning 403 — nothing else in the pipeline changes.
curl -s "https://api.sentnelops.com/mcp-calls?would_block=true" \ -H "Authorization: Bearer $SNOPS_API_KEY"
Ghost detection
The /ghosts page is a paste-driven diff, not an automatic scanner: you paste the caller identities observed at your MCP servers (one per line — agent id, 8-character id prefix, or name) and it diffs them against the registry. Three buckets come back: matched (caller maps to a registered agent), ghosts (calling your servers, registered nowhere — nobody owns them), and silent (registered but absent from traffic — decommission candidates). Traffic routed through the gateway can't be a ghost — it authenticates as a registered agent — so ghosts are, by construction, callers bypassing the proxy.
The latency budget and the two caches
The integration suite asserts p99 proxy overhead under a 15 ms budget; measured overhead in practice is about 2 ms. That budget cannot survive an awaited Postgres round trip per call, which is why the hot path carries two small in-memory caches, both governed by the same TTL (proxy_identity_ttl_seconds, default 5):
| Cache | Keyed by | Staleness bound |
|---|---|---|
| Identity + upstream | (agent_id, server) | A suspension or scope change takes effect within the TTL. Positive lookups only — new registrations are never delayed. |
| Token revocation | SHA-256 of the exact token | A revoked token keeps working for at most the TTL — the staleness this path explicitly accepts. |
CIMD: the resolvable identity document
Every agent gets a public identity document at GET /cimd/{agent_id}.json — name, owner, environment, risk level, status, and allowed servers; metadata only, never secrets. An MCP server receiving a call can resolve who is calling before it answers, which puts the gateway inside the MCP identity fabric rather than merely in front of it.