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, ortransportfields. 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
reasonstring 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
bodyamaskverdict 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
Related
- How-to: tail audit log
- Error code reference —
actionvalue mappings - Telemetry reference — corresponding span attributes