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#
- 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). Withtls.svidModeoptionalorrequired, 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, reasonsmissing_token,token_expired,token_signature_invalid,token_audience_mismatch,invalid_token,svid_*ortenant_mismatch. - Resolve the MCP server (
upstreamFor).{server}is resolved for the verified caller: the agent'sserverRefbinding, the caller namespace's ownMCPServer, exactly one server shared with the namespace, staticupstreams, then an implicitupstreamURLbinding. Otherwise HTTP 404 withunknown_server,ambiguous_serverorcross_tenant_server, audited asdeny. The request body is then read, up tomaxBodyBytes(default 4 MiB). - Kill switch (
rejectRevokedMCP). The token's session, agent, user and token ID are matched against the revocation list. A match is-32001withreason: revokedandpolicy: revocation/<id>. An unreadable list denies every call (revocation_list_unavailable). - 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 withinsufficient_scope. - MCP protocol checks (
mcpPrelude). The protocol revision is taken from_metaor theMCP-Protocol-Versionheader (2025-03-26, 2025-06-18, 2025-11-25 or 2026-07-28).Mcp-MethodandMcp-Nameheaders 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 fromrequestStateorX-Maqpna-Approval-Id. - DLP on the arguments (
dlpToolArgs). The bound DLP profile redacts matches in place ([REDACTED:<detector>]) or denies withdlp:<detector>; the upstream is not called. An unknown profile denies (dlp:profile_unknown). See DLP decision flow. - Scope (
hasServerScope). The token must granttools:<server>(or a glob such astools:*), else-32003withmissing_scope:tools:<server>. - Sandbox pre-checks and guards. The built-in
maqpna-webtool checks its domain policy; optional injection classifiers (guards.toolArgs) may block the call or taint the session before policy runs. - Tool pin (
checkToolPin). The tool's definition hash is compared with its pin. Inenforcemode 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, peronChange. - Policy (
govDecide). The engine evaluates every applicableToolPolicywith 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'smaxCallsPerMinuteis checked last and only if the call would go through (rate_limited). See policy evaluation. - Budgets. First the legacy per-session limit
sessionBudgetUSD, then the hierarchical budgets (agent, namespace, user × session, day, month). Exhausted withonExceed: denyisbudget_exceeded:<scope>/<window>;require_approvalturns an allowed call into a held one. See budget enforcement. - Per-user credentials (
perUserPrecheck). For anMCPServerwithauth.perUser, the user's connected account must exist in the token vault, elseconsent_required(with aconnectURL),principal_unverifiedorper_user_unavailable. - Decide.
denywrites adenyrecord and returns-32001.require_approvalgoes 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. - Intent record (only with
auditFailurePolicy: closed). A durable record withext.phase=intentis appended before forwarding. If it cannot be written, the call is refused withaudit_unavailable. - Forward (
forward). The agent'sAuthorization, cookies and any client-suppliedX-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 headersX-Maqpna-Namespace,-Agent,-Session,-User,-Subject,-Groups(or only aTxn-TokenintxnTokens.mode: replace), and the trace context. The connection goes through the residency-checking dialer. Server-Sent Events (SSE) responses stream through. - Response checks.
tools/listresults 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-32001and the record is stillallowwithdlp:<detector> response_withheld(the tool already ran). Optional result guards can withhold a held result. - Complete (
completeAllowed). Taint labels fromtoolLabels(and from untrusted servers) are added to the session, cleaners remove labels after a 2xx, the cost is metered, and theallowrecord is appended. In closed mode, a held (non-streamed) result is released only after that record is durable; otherwise the agent getsaudit_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 |