Audit log schema

mcpgw writes one JSONL line per request to audit.path. Lines are appended atomically; concurrent writes from a single mcpgw process are safe.

This document is the field-level reference. For querying patterns see How-to: tail audit log.


Record shape

Every record is a single-line JSON object with the following fields. Order in the wire output is not guaranteed; sort with jq if visual stability matters.

{
  "ts": "2024-01-15T14:23:01.234Z",
  "session_id": "claude-desktop-3f2a",
  "method": "tools/call",
  "tool_name": "fs_read",
  "action": "allow",
  "latency_ms": 47
}

Fields tagged omitempty (most of them) are absent when empty, so a real line is usually this short. A denied request adds rule_id/error_kind; an authenticated request adds auth_key_id/auth_result or the oauth_* fields.


Field reference

This table is the exact set of fields emitted by the audit.Entry struct. Every field except event_id, ts, session_id, method, action, and latency_ms is omitempty and is absent from the line when empty/zero.

Field Type Always present Notes
event_id string (32 hex chars) yes Unique id for this record (128-bit random, stamped at write time). Remote sinks deliver at-least-once — a record can arrive more than once after a retried flush — so dedupe downstream on this field.
ts string (RFC3339, ms precision, UTC) yes Wall-clock at the moment the audit line was committed.
session_id string yes From Mcp-Session-Id header; empty string if absent.
method string yes JSON-RPC method, e.g. tools/call. Empty if the request was rejected before parse.
tool_name string no (omitempty) Tool name for tools/call requests; absent otherwise.
protocol_version string no (omitempty) MCP protocol revision the request declared (_meta wins over the MCP-Protocol-Version header). Absent when the client declared none.
task_handle string no (omitempty) Tasks-extension taskId (SEP-2663): the handle minted in this request’s response (resultType: "task"), or params.taskId on tasks/get/tasks/update/tasks/cancel. Correlates one logical task across its create/poll/cancel RPCs.
subscription_id string no (omitempty) _meta["io.modelcontextprotocol/subscriptionId"] tag on a subscriptions/listen notification frame (frame-level audit lines only).
direction string no (omitempty) Direction of the evaluated envelope. "server_to_client" on SSE-frame audit lines. Absent on standard inbound-request audit lines.
action string yes One of: allow, deny, redact, rate_limit_blocked, strip_app, reject (pre-parse/auth rejection — see error_kind), deny_frame, redact_frame (SSE frames), synthesize_list, synthesize_search (tool-search mode).
rule_id string no (omitempty) The rule that fired; absent if no rule was involved.
auth_key_id string no (omitempty) API-key id when auth.enabled and authentication succeeded via the API-key path.
auth_result string no (omitempty) ok, missing, invalid, expired, bad_audience, bad_issuer, or insufficient_scope when auth.enabled.
oauth_client_id string no (omitempty) OAuth client_id (or azp fallback) of the verified Bearer token. Absent when auth is disabled or the API-key path was used.
oauth_client_name string no (omitempty) Human-readable client name from the CIMD document. Populated only when auth.oauth.cimd.enabled: true AND the verified token’s client_id is an https:// URL AND the CIMD endpoint returned a valid document.
scopes string no (omitempty) Space-separated scopes from the verified token’s scope (or scp) claim.
latency_ms int yes Total processing time in milliseconds, including upstream latency for forwarded requests.
error_code int no (omitempty) JSON-RPC error code returned to the client on failures (see error-codes.md).
error_kind string no (omitempty) Short machine error tag for non-success outcomes: input_rate_limited, body_too_large, read_body, parse_error, unauthorized, no_route, header_mismatch (a Mcp-Method/Mcp-Name routing header disagreed with the body), unsupported_protocol_version (declared revision not in allowed_protocol_versions), upstream_invalid, upstream_unreachable, upstream_read, upstream_too_large, sse_truncated (an SSE response was cut mid-stream — e.g. a frame exceeded max_sse_frame_bytes; the client received a terminal event: error frame), policy_denied (a deny on the buffered response path, including a guardrail reject verdict there), guardrail_unreachable (a guardrail webhook callout failed and the endpoint’s fail_open is false; the request was denied), guardrail_body_too_large (the envelope exceeded the guardrail endpoint’s max_body_bytes and was refused before any callout, with fail_open false).
stripped int no (omitempty) Count of UI blocks removed by a strip_app action. 0 (and thus omitted) means the rule matched but no UI blocks were found.
guardrail_name string no (omitempty) Guardrail endpoint behind a matched guardrail rule, when a webhook callout was attempted. See Guardrail fields below.
guardrail_verdict string no (omitempty) pass, mask, reject, or error_failopen. Empty on a fail-closed callout failure — error_kind (guardrail_unreachable or guardrail_body_too_large) is the signal there.
guardrail_latency_ms int no (omitempty) Wall time of the webhook Check call alone (distinct from latency_ms, which covers the whole request).

The gateway does not emit request_id, client_ip, upstream, bytes_in, bytes_out, status, or transport fields. Earlier drafts of this page documented those — they were never wired up.


Rotation

mcpgw rotates the audit file when its size exceeds audit.max_size_mb. Rotation is rename-then-open: the active file is renamed to <path>.<unix-millis> and a fresh file is opened at the original path. If audit.compress_rotated: true, the rotated file is gzipped asynchronously to <path>.<unix-millis>.gz.

mcpgw never deletes rotated files. Retention is your responsibility — log shippers, GCS/S3 lifecycle policies, find -mtime, etc.

Rotation does not write a marker line into either file; operational events (reload, license rotation, rotation) are logged to stderr/journald via slog, not into the audit JSONL. The audit file contains per-request lines only.

Tool-search actions

When tool_search.mode: synthesize is enabled, the gateway responds to tools/list and tools/call mcp_search from its own index rather than forwarding to an upstream. These responses appear in the audit log with the following action values:

action Description
synthesize_list Gateway responded to tools/list with the virtual mcp_search stub, served from the synthesizer.
synthesize_search Gateway responded to tools/call mcp_search with index search results, served from the synthesizer.

Filter synthesized responses:

jq 'select(.action | startswith("synthesize"))' audit.jsonl

Synthesized responses are not forwarded to any upstream and are not subject to policy deny rules. They do not carry an upstream field.


SSE frame actions

When direction: server_to_client policy rules are configured, mcpgw evaluates each JSON-RPC envelope in the SSE response stream. These evaluations produce separate audit lines alongside the parent request’s line. The direction field is set to "server_to_client" on all frame-level lines.

action Description
allow Frame matched no rule, or matched an allow rule, and was forwarded to the client.
deny_frame Frame was dropped by a deny rule. The client never receives it.
redact_frame Frame’s data: bytes were rewritten by a redact rule before forwarding.
rate_limit_blocked Frame was dropped because a rate_limit rule’s bucket was empty.
strip_app UI blocks removed from a tools/call response frame; direction=server_to_client. The stripped field counts blocks removed.

Filter server-to-client frame lines:

jq -c 'select(.direction == "server_to_client")' audit.jsonl

Filter for dropped frames specifically:

jq -c 'select(.direction == "server_to_client" and (.action == "deny_frame" or .action == "rate_limit_blocked"))' audit.jsonl

Guardrail fields

When an action: guardrail rule matched and a webhook callout was attempted (in either direction), the request’s single audit line carries the guardrail_* marker fields. The action field keeps its normal vocabulary — pass maps to allow, mask to redact, reject to deny — and the marker fields carry the guardrail provenance on top:

guardrail_verdict Meaning
pass Webhook returned pass; the envelope continued unchanged.
mask Webhook returned mask + body; the mutated body was forwarded (action: redact).
reject Webhook returned reject; the request was denied (action: deny, -32001 policy_denied).
error_failopen The callout failed (non-200, timeout, malformed verdict) and the endpoint’s fail_open: true; the envelope continued (action: allow). A security control was bypassed — the gateway also logs a warning per failed call.

A fail-closed callout failure (fail_open: false, the default) denies the request: action: deny, guardrail_verdict absent, and an error_kind naming the cause — guardrail_unreachable when the callout failed, or guardrail_body_too_large when the envelope exceeded max_body_bytes and was refused before any callout. The verdict vocabulary is deliberately exactly the four values above, so a missing verdict plus one of those error kinds is unambiguous. (Aside: a verdict reject carries no error_kind on the request path, like a plain deny rule; on the buffered response path it carries policy_denied, like that path’s own deny.)

Two guarantees and one disclosure:

  • Metadata-only. The webhook’s reason string is never persisted to audit — it goes to gateway debug logs (slog.Debug) and nowhere else. Even though the webhook receives the full JSON-RPC body, what mcpgw stores about the decision is name, verdict, and latency.
  • No body echo. The body a mask verdict substituted is never audited either; request and response bodies are never written to the audit log.
  • Dual-direction last write wins. If callouts fire on both the request and the buffered response of one call, the response-direction marker overwrites the request-direction one on the single audit line (consistent with the span attributes). The request-direction verdict is then not visible in audit.

Filter guardrail-affected requests:

jq -c 'select(.guardrail_name != null)' audit.jsonl

# Fail-open bypasses specifically
jq -c 'select(.guardrail_verdict == "error_failopen")' audit.jsonl

mcpgw policy replay cannot re-derive these decisions offline (the verdict is not in the log, bodies never are), so records with guardrail_name set are counted as guardrail_skipped and excluded from the divergence gate. See How-to: enable a guardrail webhook.


Auth queries

# Failed auth attempts (all paths)
jq -c 'select(.auth_result == "missing" or .auth_result == "invalid" or .auth_result == "expired" or
              .auth_result == "bad_audience" or .auth_result == "bad_issuer" or .auth_result == "insufficient_scope")' audit.jsonl

# Requests by API key id
jq -r 'select(.auth_key_id != null) | .auth_key_id' audit.jsonl | sort | uniq -c | sort -rn

# Requests by OAuth client id
jq -r 'select(.oauth_client_id != null) | .oauth_client_id' audit.jsonl | sort | uniq -c | sort -rn

Integrity properties

  • Append-only at the OS level. mcpgw opens the file with O_APPEND; concurrent writes from a single process do not interleave within a line on POSIX.
  • No HMAC, no chain. v1 does not sign or chain audit lines. For tamper-evidence, ship to a write-once destination (GCS Bucket Lock, S3 Object Lock, or a SIEM in WORM mode).
  • No backfill. Once a line is committed, mcpgw does not modify it. Time-skew corrections happen on the read side, not the write side.

Sample queries

# All non-allow decisions in the last hour
jq -c --arg since "$(date -u -v-1H +%FT%T)" \
  'select(.ts > $since and .action != "allow")' audit.jsonl

# Top 10 sessions by request volume
jq -r '.session_id' audit.jsonl | sort | uniq -c | sort -rn | head

# Latency by tool (rough)
jq -r 'select(.tool_name != null) | "\(.tool_name)\t\(.latency_ms)"' audit.jsonl \
  | sort -k1,1 -k2,2n \
  | awk '{a[$1]=a[$1]" "$2} END{for(k in a) print k, a[k]}'

# Rejected requests by error kind
jq -r 'select(.error_kind != null) | .error_kind' audit.jsonl | sort | uniq -c | sort -rn