MAQPNADocs

A governed tool call#

This page follows one Model Context Protocol (MCP) tools/call from an agent through the MAQPNA gateway to an MCP server and back. Every step below is a function in cmd/maqpna-gateway (mcp.go handleMCPPost, handleSingle and authorizeCall), listed in the order it runs. Every outcome, including every denial, ends in exactly one decision in the audit ledger.

The pipeline at a glance#

flowchart TD
    A["POST /mcp/server<br/>Bearer session token"] --> B["1 Authenticate<br/>token, SVID, tenant key"]
    B -->|fail| X1["401 · -32003"]
    B --> C["2 Resolve MCP server<br/>for this namespace"]
    C -->|unknown| X2["404 · unknown_server"]
    C --> D["3 Kill switch"]
    D -->|revoked| X3["-32001 · revoked"]
    D --> E["4 External client step-up scope"]
    E --> F["5 MCP protocol checks<br/>version, headers"]
    F --> G["6 DLP on arguments"]
    G -->|deny| X4["-32001 · dlp:detector"]
    G --> H["7 Scope tools:server"]
    H -->|missing| X5["-32003 · missing_scope"]
    H --> I["8 Sandbox pre-checks and<br/>injection guards"]
    I --> J["9 Tool pin"]
    J --> K["10 Policy + rate limit<br/>(session taints are an input)"]
    K --> L["11 Budgets"]
    L --> M["12 Per-user credential check"]
    M --> N{"Decision"}
    N -->|deny| X6["-32001 · audit deny"]
    N -->|require_approval| P["13 Hold for approval<br/>then re-check kill switch"]
    P -->|denied or expired| X6
    N -->|allow| Q["14 Intent record<br/>(closed audit mode only)"]
    P -->|approved| Q
    Q --> R["15 Forward with gateway credential<br/>and identity headers"]
    R --> S["16 DLP on the response<br/>and result guards"]
    S --> T["17 Taint, meter, audit allow"]
    T --> U["Result to the agent"]

Sequence#

sequenceDiagram
    autonumber
    participant Ag as Agent (sandbox)
    participant GW as Gateway
    participant RL as Revocation list
    participant PE as Policy engine
    participant AQ as Approval store
    participant Up as MCP server
    participant L as Audit ledger
    Ag->>GW: POST /mcp/github tools/call (Bearer token)
    GW->>GW: verify Ed25519 token, SVID, tenant key
    GW->>GW: resolve github for namespace and agent
    GW->>RL: session, agent, user, jti revoked?
    RL-->>GW: no
    GW->>GW: DLP on arguments (redact or deny)
    GW->>GW: scope tools:github, guards, tool pin
    GW->>PE: decide (namespace, agent, user, groups, scopes, tool, args, taints, sinks)
    PE-->>GW: allow, deny or require_approval
    GW->>GW: session and hierarchical budgets
    opt require_approval
        GW->>AQ: create, wait (or return -32002 when async)
        AQ-->>GW: approved
        GW->>RL: re-check after the wait
    end
    opt auditFailurePolicy closed
        GW->>L: intent record (ext.phase intent)
    end
    GW->>Up: forward (agent token stripped, gateway credential, X-Maqpna-* headers)
    Up-->>GW: result
    GW->>GW: DLP on the response, result guards
    GW->>GW: add or clear taint labels, meter cost
    GW->>L: append record decision allow
    GW-->>Ag: result

Step by step#

  1. Authenticate (authenticate, verifyRequest). The bearer token is checked in this order: a token from a trusted customer identity provider (external MCP clients, audience must be <resourceBaseURL>/mcp/<server>); otherwise a MAQPNA session token, verified as an EdDSA JWT against the broker's public keys (an unknown key ID triggers one JWKS refresh). With tls.svidMode optional or required, the client certificate must be an X.509-SVID equal to the token subject. Tenant tokens must carry their tenant's key ID and trust domain. Failure: HTTP 401, JSON-RPC -32003, reasons missing_token, token_expired, token_signature_invalid, token_audience_mismatch, invalid_token, svid_* or tenant_mismatch.
  2. Resolve the MCP server (upstreamFor). {server} is resolved for the verified caller: the agent's serverRef binding, the caller namespace's own MCPServer, exactly one server shared with the namespace, static upstreams, then an implicit upstreamURL binding. Otherwise HTTP 404 with unknown_server, ambiguous_server or cross_tenant_server, audited as deny. The request body is then read, up to maxBodyBytes (default 4 MiB).
  3. Kill switch (rejectRevokedMCP). The token's session, agent, user and token ID are matched against the revocation list. A match is -32001 with reason: revoked and policy: revocation/<id>. An unreadable list denies every call (revocation_list_unavailable).
  4. Step-up scope for external clients (rejectExternalScope). A token from an identity provider that lacks the server's scope (or a step-up scope for this tool) gets HTTP 403 with insufficient_scope.
  5. MCP protocol checks (mcpPrelude). The protocol revision is taken from _meta or the MCP-Protocol-Version header (2025-03-26, 2025-06-18, 2025-11-25 or 2026-07-28). Mcp-Method and Mcp-Name headers must match the body (-32020, header_mismatch); an unsupported revision gets -32022. Batches are split and every message runs this pipeline. For a retry of a held call, the approval ID is read from requestState or X-Maqpna-Approval-Id.
  6. DLP on the arguments (dlpToolArgs). The bound DLP profile redacts matches in place ([REDACTED:<detector>]) or denies with dlp:<detector>; the upstream is not called. An unknown profile denies (dlp:profile_unknown). See DLP decision flow.
  7. Scope (hasServerScope). The token must grant tools:<server> (or a glob such as tools:*), else -32003 with missing_scope:tools:<server>.
  8. Sandbox pre-checks and guards. The built-in maqpna-web tool checks its domain policy; optional injection classifiers (guards.toolArgs) may block the call or taint the session before policy runs.
  9. Tool pin (checkToolPin). The tool's definition hash is compared with its pin. In enforce mode a drifted, unpinned or unobserved tool is hidden and denied (tool_definition_changed, tool_unpinned, tool_definition_unverified), or escalated to approval, or only flagged, per onChange.
  10. Policy (govDecide). The engine evaluates every applicable ToolPolicy with the namespace, agent, user, verified groups, scopes, tool, (redacted) arguments, the session's current taint labels and the tool's sink labels. Strictest wins; no applicable policy is a deny. Dry-run policies add a ledger record of what they would have done. A rule's maxCallsPerMinute is checked last and only if the call would go through (rate_limited). See policy evaluation.
  11. Budgets. First the legacy per-session limit sessionBudgetUSD, then the hierarchical budgets (agent, namespace, user × session, day, month). Exhausted with onExceed: deny is budget_exceeded:<scope>/<window>; require_approval turns an allowed call into a held one. See budget enforcement.
  12. Per-user credentials (perUserPrecheck). For an MCPServer with auth.perUser, the user's connected account must exist in the token vault, else consent_required (with a connectURL), principal_unverified or per_user_unavailable.
  13. Decide. deny writes a deny record and returns -32001. require_approval goes to the approval flow: synchronous callers wait; asynchronous callers (X-Maqpna-Async: 1) get -32002. After an approval wait, the kill switch is checked again (recheckRevoked), so a revocation issued during the wait still stops the call.
  14. Intent record (only with auditFailurePolicy: closed). A durable record with ext.phase=intent is appended before forwarding. If it cannot be written, the call is refused with audit_unavailable.
  15. Forward (forward). The agent's Authorization, cookies and any client-supplied X-Maqpna-* headers are stripped. The gateway adds its own upstream credential (bearer, header, OAuth client credentials, token exchange or mTLS, or the user's own token from the vault), the identity headers X-Maqpna-Namespace, -Agent, -Session, -User, -Subject, -Groups (or only a Txn-Token in txnTokens.mode: replace), and the trace context. The connection goes through the residency-checking dialer. Server-Sent Events (SSE) responses stream through.
  16. Response checks. tools/list results are filtered to tools this caller may use. Response DLP scans the whole JSON-RPC response (and each SSE event); a deny withholds the result with -32001 and the record is still allow with dlp:<detector> response_withheld (the tool already ran). Optional result guards can withhold a held result.
  17. Complete (completeAllowed). Taint labels from toolLabels (and from untrusted servers) are added to the session, cleaners remove labels after a 2xx, the cost is metered, and the allow record is appended. In closed mode, a held (non-streamed) result is released only after that record is durable; otherwise the agent gets audit_unavailable.

What you see#

A denied call is an in-band JSON-RPC error (HTTP 200). The message comes from deniedError in mcp.go and error.data follows the error envelope; values are illustrative:

{"jsonrpc":"2.0","id":3,"error":{"code":-32001,"message":"denied by policy prod-guard rule no-prod: matched rule no-prod","data":{"detail":"matched rule no-prod","domain":"maqpna.com","policy":"prod-guard","reason":"policy_denied","rule":"no-prod"}}}

The ledger record for the same call:

{"seq":1043,"time":"2026-10-02T14:05:09.123456789Z","session":"fix-4821","namespace":"team-a","agent":"coder","user":"alice@acme.eu","server":"kubernetes","tool":"delete_pod","argsSha256":"9f2c…","decision":"deny","rule":"prod-guard/no-prod","reason":"matched rule no-prod","approver":"","latencyMs":0.4,"costUsd":0,"prevHash":"ab31…","hash":"77e0…","schema":2,"ext":{"traceId":"4bf92f3577b34da6a3ce929d0e0e4736"}}

maqpna dev timeline --last and maqpna session describe show the same call as one timeline step. To make a single call by hand, use maqpna call.

Failure modes#

Situation Behaviour
Policies not loaded at start /readyz fails; calls are denied (no applicable policy)
Revocation list missing or invalid Every call denied, revocation_list_unavailable; /readyz 503
Upstream credential missing HTTP 502, -32603, upstream_credential_unavailable; nothing is sent
Upstream unreachable HTTP 502, upstream_unavailable
Ledger append fails, auditFailurePolicy: open Call proceeds; maqpna_gateway_audit_errors_total counts it
Ledger append fails, auditFailurePolicy: closed Call refused (intent) or result withheld (completion), audit_unavailable
Cedar policy in a binary without -tags cedar Deny, cedar_unavailable