Gateway request pipeline#
This page opens up cmd/maqpna-gateway for engineers who need to know exactly what happens, in which order, to one request. For the reader-level story see a governed tool call; for the component view see gateway.
Listeners and middleware#
| Listener | Address | Serves |
|---|---|---|
| Data listener | listen (default :8080) |
Everything, unless adminListen is set. HTTPS when tls.certFile/keyFile are set; with tls.svidMode the TLS layer requests (never requires at handshake) a client X.509-SVID |
| Admin listener (optional) | adminListen |
Plain HTTP. The whole handler plus /debug/pprof/* (admin role). When it is set, admin routes, /metrics and /version answer 404 on the data listener |
The data server sets ReadHeaderTimeout 10 s and IdleTimeout 120 s and deliberately no write timeout, because SSE streams and synchronous approval waits are long-lived.
The root handler is egressProxy(cors(mux)):
egressProxy: whenegress.enabled,CONNECTand absolute-form requests are the browser forward proxy (handleEgress) and never reach the mux.cors: applies only to/v1/*and only forcorsOrigins.mux: Go 1.22 pattern routes.
| Route family | Authenticated by | Examples |
|---|---|---|
| Model Context Protocol (MCP) | Session token (or a trusted external IdP token) | POST/GET/DELETE /mcp/{server}, built-in /mcp/memory, /mcp/maqpna-web |
| Model calls | Session token | /llm/{route}/v1/chat/completions, completions, embeddings, models |
| Agent-to-agent (A2A) | Session token | POST /a2a/{ns}/{agent}, /a2a/{ns}/{agent}/{rest...}, GET /a2a/jwks.json |
| Sandbox tools | Session token or admin | /v1/sessions/{id}/exec, /files, /evidence, /liveview |
| Agent self-service | Session token | POST /v1/sessions/self/result |
| Operator usage | Operator ServiceAccount token (TokenReview) | POST /v1/usage/sandbox |
| Admin API | OIDC access token or static admin token, by role | /v1/approvals, /v1/audit/*, /v1/revocations, /v1/policies:evaluate, /v1/sessions, /v1/posture, /v1/license, ... |
| Public | None (or signature) | /healthz, /readyz, /metrics, /version, /v1/auth/config, /v1/audit/jwks, chat callbacks, /.well-known/oauth-protected-resource/mcp/{server} |
The stages of an MCP tools/call#
flowchart TD
A["POST /mcp/server"] --> B["verifyRequest<br/>bearer, external IdP, Ed25519 JWT,<br/>checkSVID, tenant key binding, fork"]
B -- fail --> X1["401 · -32003"]
B --> C["upstreamFor<br/>binding, own ns, shared, static, implicit"]
C -- fail --> X2["404 · unknown_server"]
C --> D["rejectRevokedMCP<br/>kill switch"]
D -- match --> X3["-32001 · revoked"]
D --> E["mcpPrelude<br/>protocol version, header check,<br/>approval retry state"]
E --> F["dlpToolArgs<br/>request DLP"]
F -- deny --> X4["-32001 · dlp:detector"]
F --> G["authorizeCall"]
subgraph AZ["authorizeCall"]
G1["scope tools:server"] --> G2["sandboxPreAuthorize<br/>web domain policy"]
G2 --> G3["guardToolArgs<br/>injection classifiers"]
G3 --> G4["govPreAuthorize<br/>groups, taints, sinks, checkToolPin"]
G4 --> G5["govDecide<br/>engine.Decide + rate limit"]
G5 --> G6["sessionBudgetUSD +<br/>budgetDecisionMCP"]
G6 --> G7["perUserPrecheck<br/>token vault consent"]
end
G --> G1
G7 --> H{decision}
H -- deny --> X5["finishDenied + deniedError<br/>-32001, audited"]
H -- require_approval --> I["handleApproval<br/>sync wait or async -32002"]
I --> I2["recheckRevoked"]
H -- allow --> J
I2 --> J["auditIntent<br/>closed mode only"]
J -- fail --> X6["audit_unavailable"]
J --> K["auditFirstWriter holds the result"]
K --> L["forward<br/>upstream credential, Txn-Token,<br/>identity headers"]
L --> M["dlpModifyResponse<br/>response DLP, tools/list filter"]
M --> N["guardToolResult"]
N --> O["completeAllowed<br/>taint, data class, meter, ledger append"]
O --> P["withholdUnaudited<br/>closed mode"]
P --> Q["release result to the agent"]
Step by step (mcp.go, policy_governance.go, auditpolicy.go, auditorder.go):
verifyRequest. Takes the bearer token (missing_token), tries trusted external issuers (F-16), then verifies the broker's EdDSA JWT (on an unknown key ID it refreshes the JWKS once).checkSVIDbinds an X.509-SVID to the token subject whensvidModeisoptionalorrequired.afterVerifychecks tenant key binding (tenant_mismatch) and applies fork inheritance.upstreamForresolves{server}for the verified caller: the agent's binding, then the caller namespace's server, then exactly one shared server, then staticupstreams, then an implicitupstreamURL. Otherwise 404 withunknown_server,ambiguous_serverorcross_tenant_server.- The body is read up to
maxBodyBytes(default 4 MiB; larger is 413). rejectRevokedMCPchecks the kill switch. A missing or invalid revocation list denies everything (revocation_list_unavailable).rejectExternalScopehandles step-up scopes for external clients (403insufficient_scope).- Batches are split (
handleBatch) and each message goes through the same hooks; MCP 2026-07-28 forbids batches with MCP headers. mcpPreludepicks the protocol revision (2026-07-28,2025-11-25,2025-06-18,2025-03-26), starts the span, checksMcp-Method/Mcp-Nameheaders against the body (-32020), and turns an MRTRrequestStateinto an approval-retry ID.dlpToolArgsscans the arguments. Redaction rewrites them in place, so policy and approvers see the redacted version;argsSha256stays the hash of the original.authorizeCallruns, in order: the scope check (tools:<server>ortools:*), the web domain pre-check, injection guards (which may taint the session first), tool pinning (checkToolPin), the policy engine (govDecide, includingmaxCallsPerMinuteand dry-run records), the legacysessionBudgetUSD, hierarchical budgets (budgetDecisionMCP) and the token-vault consent check.- Deny:
finishDeniedwrites the ledger record anddeniedErrorreturns-32001. Require approval:handleApproval(below), thenrecheckRevoked, because a revocation may have arrived during the wait. auditIntentwrites a durable intent record (ext.phase=intent) whenauditFailurePolicyisclosed; if it fails the call is refused withaudit_unavailableand nothing is forwarded.auditFirstWriterbuffers a non-streamed result (up to 16 MiB) so it is released only after its ledger record is written.forwardadds the upstream credential (withUpstreamCredential), a Txn-Token if configured, and theX-Maqpna-*identity headers; it stripsAuthorization, cookies and any client-suppliedX-Maqpna-*orTxn-Token.dlpModifyResponsefilterstools/listto visible tools and scans the response (every method, errors, SSE events).guardToolResultcan withhold a held result flagged by a classifier.completeAllowedapplies taint labels (govAfterCall), notes the data class, meters the cost and appends theallowrecord.withholdUnauditeddrops the held result in closed mode if the completion record could not be written; otherwise the result is released.
handleApproval#
| Case | What happens |
|---|---|
Retry with X-Maqpna-Approval-Id |
Must match session, namespace, agent, server, tool and argument hash. Still pending: -32002 again (not audited). Decided: Consume (single use) |
Async (X-Maqpna-Async: 1) |
approvals.Create, a require_approval ledger record with approval_pending:<id>, then -32002 (or input_required for 2026-07-28 clients) |
| Sync (default) | approvals.Wait blocks the HTTP request (span approval.wait). Approved: consume and continue. Expired: approval_expired. Denied: approval_denied by <approver>. Client gone: approval_aborted |
Hot reload of rendered files#
The gateway reads the operator's output as files and polls each every policyReloadSeconds (default 2): policyFile, modelsFile, upstreamsFile, a2a.routesFile, revocationsFile, budgetsFile, memoryFile, tenantsFile, plus credential directories. A file that fails to parse keeps the previous version (the revocation list is the exception: it fails closed). With configSync.enabled (chart default) the gateway also reads the ConfigMaps from the Kubernetes API every configSync.pollMillis (default 1000) and writes the files atomically, so changes take effect in about one to three seconds instead of waiting for the kubelet's volume refresh (60 to 90 seconds).
Per replica and shared#
| Per replica (in memory) | Shared with stateBackend: postgres |
|---|---|
| Loaded policies and the policy engine | Approvals (decide and consume under a cluster-wide lock) |
maxCallsPerMinute sliding windows |
Break-glass revocations, taint, tool pins, notifier outbox |
| Guard state, model-routing circuit breakers | The audit hash chain (one chain, mirrored to auditPath on each replica) |
| Concurrency counters | FinOps meter snapshot (other replicas' spend seen about 1 s later) |
tokensPerMinute and requestsPerMinute fixed windows |
|
| Token vault, memory documents, captures, tenant ledgers |
What you see#
A policy denial on the MCP route, as the agent receives it (format from deniedError and envelope.go):
{"jsonrpc":"2.0","id":3,"error":{"code":-32001,"message":"denied by policy prod-guard rule no-prod: matched rule no-prod","data":{"domain":"maqpna.com","reason":"policy_denied","detail":"matched rule no-prod","policy":"prod-guard","rule":"no-prod"}}}
/readyz returns 200 {"status":"ready"}, or 503 with the problems that keep the replica out of the Service:
{"status":"not ready","problems":["revocation list unavailable (failing closed): open /etc/maqpna/revocations/revocations.json: no such file or directory"]}
The possible problems are policies not loaded, no identity verification keys, revocation list unavailable (failing closed): …, state backend unreachable: … and audit ledger failing (auditFailurePolicy=closed).