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",
"request_id": "req_01HXYZ7K2J3M4N5P6Q7R8S9T0V",
"session_id": "claude-desktop-3f2a",
"client_ip": "203.0.113.42",
"method": "tools/call",
"tool": "fs_read",
"upstream": "filesystem",
"action": "allow",
"rule_id": "",
"bytes_in": 312,
"bytes_out": 1024,
"status": 200,
"duration_ms": 47,
"transport": "http+sse",
"error": ""
}
Field reference
| Field | Type | Always present | Notes |
|---|---|---|---|
ts | string (RFC3339, ms precision, UTC) | yes | Wall-clock at the moment the audit line was committed. |
request_id | string | yes | Stable correlation id, also in span mcp.request.id and response header X-MCPGW-Request-ID. |
session_id | string | yes | From Mcp-Session-Id header; empty if absent. |
client_ip | string | yes | RemoteAddr host. Never derived from X-Forwarded-For. See Explanation: rate-limit identity. |
method | string | yes | JSON-RPC method, e.g. tools/call. Empty if the request was rejected before parse. |
tool | string | yes | Tool name for tools/call requests; empty otherwise. |
upstream | string | yes | Logical upstream name from config; empty if the request was rejected before routing. |
action | string | yes | One of: allow, deny, redact, rate_limit_blocked, strip_app, deny_frame, redact_frame, input_rate_limited, parse_error, body_too_large, read_body, no_route, upstream_unreachable, upstream_timeout, license_invalid, synthesize_list, synthesize_search. |
direction | string | no | Direction of the evaluated envelope. "server_to_client" on SSE-frame audit lines (Phase 4 and later). Empty on standard inbound-request audit lines. |
stripped | int | no (omitempty) | Count of UI blocks removed by a strip_app action. Present only when action == "strip_app"; omitted on all other actions. 0 means the rule matched but no UI blocks were found in the response. |
rule_id | string | yes | The rule that fired, or empty if no rule was involved. |
auth_key_id | string | no | API-key id when auth.enabled and authentication succeeded via the API-key path. |
auth_result | string | no | ok, missing, invalid, expired, bad_audience, bad_issuer, or insufficient_scope when auth.enabled. |
oauth_client_id | string | no | OAuth client_id (or azp fallback) of the verified Bearer token. Empty when auth is disabled or the API-key path was used. |
scopes | string | no | Space-separated scopes from the verified token’s scope (or scp) claim. Empty 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. Empty on API-key path or when CIMD is disabled / failed. |
bytes_in | int | yes | Length of the request body. 0 for pre-receive failures. |
bytes_out | int | yes | Length of the response body. 0 on errors before any upstream byte was observed. |
status | int | yes | HTTP status returned to the client. |
duration_ms | int | yes | Total processing time, including upstream latency for forwarded requests. |
transport | string | yes | http, http+sse, sse, or stdio-bridge. |
error | string | yes | Free-text error description for non-allow/redact decisions. Empty for success. |
System events
In addition to per-request lines, mcpgw emits a small set of system events. They use the same envelope but the event field is set and most request fields are empty.
{ "ts": "...", "event": "policy_reload", "rules": 4 }
{ "ts": "...", "event": "policy_reload_failed", "error": "regex compile failed: ..." }
{ "ts": "...", "event": "license_rotated", "subject": "acme-corp", "exp": "2025-06-01T00:00:00Z" }
{ "ts": "...", "event": "license_grace_entered", "expired_at": "..." }
{ "ts": "...", "event": "license_expired_beyond_grace" }
{ "ts": "...", "event": "audit_rotated", "old_path": "audit.jsonl.1737000000123" }
To query only request lines, filter for the absence of event:
jq -c 'select(has("event") | not)' audit.jsonl
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.
A successful rotation emits a audit_rotated event in both the old and new files (one line in each, on either side of the rotation boundary) so log shippers can stitch the stream.
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
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 p99 by tool (rough)
jq -r 'select(.tool != "") | "\(.tool)\t\(.duration_ms)"' audit.jsonl \
| sort -k1,1 -k2,2n \
| awk '{a[$1]=a[$1]" "$2} END{for(k in a) print k, a[k]}'
# Policy reloads in the last 24h
jq -c 'select(.event == "policy_reload")' audit.jsonl
Related
- How-to: tail audit log
- Error code reference —
actionvalue mappings - Telemetry reference — corresponding span attributes