MAQPNADocs

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)):

  1. egressProxy: when egress.enabled, CONNECT and absolute-form requests are the browser forward proxy (handleEgress) and never reach the mux.
  2. cors: applies only to /v1/* and only for corsOrigins.
  3. 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):

  1. 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). checkSVID binds an X.509-SVID to the token subject when svidMode is optional or required. afterVerify checks tenant key binding (tenant_mismatch) and applies fork inheritance.
  2. upstreamFor resolves {server} for the verified caller: the agent's binding, then the caller namespace's server, then exactly one shared server, then static upstreams, then an implicit upstreamURL. Otherwise 404 with unknown_server, ambiguous_server or cross_tenant_server.
  3. The body is read up to maxBodyBytes (default 4 MiB; larger is 413).
  4. rejectRevokedMCP checks the kill switch. A missing or invalid revocation list denies everything (revocation_list_unavailable).
  5. rejectExternalScope handles step-up scopes for external clients (403 insufficient_scope).
  6. Batches are split (handleBatch) and each message goes through the same hooks; MCP 2026-07-28 forbids batches with MCP headers.
  7. mcpPrelude picks the protocol revision (2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26), starts the span, checks Mcp-Method/Mcp-Name headers against the body (-32020), and turns an MRTR requestState into an approval-retry ID.
  8. dlpToolArgs scans the arguments. Redaction rewrites them in place, so policy and approvers see the redacted version; argsSha256 stays the hash of the original.
  9. authorizeCall runs, in order: the scope check (tools:<server> or tools:*), the web domain pre-check, injection guards (which may taint the session first), tool pinning (checkToolPin), the policy engine (govDecide, including maxCallsPerMinute and dry-run records), the legacy sessionBudgetUSD, hierarchical budgets (budgetDecisionMCP) and the token-vault consent check.
  10. Deny: finishDenied writes the ledger record and deniedError returns -32001. Require approval: handleApproval (below), then recheckRevoked, because a revocation may have arrived during the wait.
  11. auditIntent writes a durable intent record (ext.phase=intent) when auditFailurePolicy is closed; if it fails the call is refused with audit_unavailable and nothing is forwarded.
  12. auditFirstWriter buffers a non-streamed result (up to 16 MiB) so it is released only after its ledger record is written.
  13. forward adds the upstream credential (withUpstreamCredential), a Txn-Token if configured, and the X-Maqpna-* identity headers; it strips Authorization, cookies and any client-supplied X-Maqpna-* or Txn-Token.
  14. dlpModifyResponse filters tools/list to visible tools and scans the response (every method, errors, SSE events).
  15. guardToolResult can withhold a held result flagged by a classifier.
  16. completeAllowed applies taint labels (govAfterCall), notes the data class, meters the cost and appends the allow record.
  17. withholdUnaudited drops 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).