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

FieldTypeAlways presentNotes
tsstring (RFC3339, ms precision, UTC)yesWall-clock at the moment the audit line was committed.
request_idstringyesStable correlation id, also in span mcp.request.id and response header X-MCPGW-Request-ID.
session_idstringyesFrom Mcp-Session-Id header; empty if absent.
client_ipstringyesRemoteAddr host. Never derived from X-Forwarded-For. See Explanation: rate-limit identity.
methodstringyesJSON-RPC method, e.g. tools/call. Empty if the request was rejected before parse.
toolstringyesTool name for tools/call requests; empty otherwise.
upstreamstringyesLogical upstream name from config; empty if the request was rejected before routing.
actionstringyesOne 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.
directionstringnoDirection of the evaluated envelope. "server_to_client" on SSE-frame audit lines (Phase 4 and later). Empty on standard inbound-request audit lines.
strippedintno (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_idstringyesThe rule that fired, or empty if no rule was involved.
auth_key_idstringnoAPI-key id when auth.enabled and authentication succeeded via the API-key path.
auth_resultstringnook, missing, invalid, expired, bad_audience, bad_issuer, or insufficient_scope when auth.enabled.
oauth_client_idstringnoOAuth client_id (or azp fallback) of the verified Bearer token. Empty when auth is disabled or the API-key path was used.
scopesstringnoSpace-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_namestringno (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_inintyesLength of the request body. 0 for pre-receive failures.
bytes_outintyesLength of the response body. 0 on errors before any upstream byte was observed.
statusintyesHTTP status returned to the client.
duration_msintyesTotal processing time, including upstream latency for forwarded requests.
transportstringyeshttp, http+sse, sse, or stdio-bridge.
errorstringyesFree-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:

actionDescription
synthesize_listGateway responded to tools/list with the virtual mcp_search stub, served from the synthesizer.
synthesize_searchGateway 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.

actionDescription
allowFrame matched no rule, or matched an allow rule, and was forwarded to the client.
deny_frameFrame was dropped by a deny rule. The client never receives it.
redact_frameFrame’s data: bytes were rewritten by a redact rule before forwarding.
rate_limit_blockedFrame was dropped because a rate_limit rule’s bucket was empty.
strip_appUI 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