Skip to content

Audit log

Every tools/call produces a row in the audit_events Postgres table. Auth failures, the portal's Try-It proxy, and direct admin-API invocations all show up there too, with different source tags so you can filter by origin.

What gets recorded

Column Source
id UUID generated server-side.
ts UTC timestamp at the start of the call.
duration_ms End-to-end time the tool took, including the auth chain.
request_id UUID generated per call; useful for correlating across logs.
session_id The MCP session ID the SDK assigned at initialize.
user_subject / user_email / auth_type / api_key_name Resolved identity from the auth chain.
tool_name / tool_group Which tool, and which category.
parameters Sanitized arguments (JSONB). Keys matching audit.redact_keys have their values replaced with "[redacted]".
success / error_message / error_category Outcome. error_category is a short label (auth, tool, protocol, etc.) for filtering.
request_chars / response_chars / content_blocks Sizing of input args and the result. Useful for spotting size-cap issues.
transport Always "http" today.
source "mcp" for real client calls, "portal-tryit" for portal-driven invocations.
remote_addr / user_agent From the inbound HTTP headers.

See Database & Migrations for the exact DDL and indexes.

Browsing in the portal

The portal's Audit tab is the primary UI:

  • Time range, tool, user, success/error, and free-text search.
  • Pagination (50 rows per page).
  • Per-row drawer expanding the full event including sanitized params.
  • Title-attribute on the User cell shows the canonical user_subject (e.g. a Keycloak UUID) when the displayed value is email or API-key name.

The Dashboard tab is a 1-hour snapshot: total calls, error rate, p50 / p95 latency, unique users / tools, and a recent-activity table.

REST endpoints

Endpoint Returns
GET /api/v1/portal/audit/events Paginated event list. Query params: from, to (RFC 3339), tool, user, session, success (true/false), q (free text), limit, offset.
GET /api/v1/portal/audit/timeseries Bucketed counts. Query params: from, to, bucket (Go duration like 1m, 5m). Returns [{time, count, errors, avg_duration_ms}].
GET /api/v1/portal/audit/breakdown Group-by aggregations. Query param: by (one of tool, user, success, auth_type). Returns [{key, count, errors}].
GET /api/v1/portal/dashboard The 1-hour summary.

All of these accept either the session cookie (browser) or X-API-Key / Authorization: Bearer.

Direct SQL

For analyses the portal doesn't cover:

-- Rate of calls per user per minute, last 24h
SELECT date_trunc('minute', ts) AS bucket, user_subject, count(*)
FROM audit_events
WHERE ts >= now() - interval '1 day'
GROUP BY bucket, user_subject
ORDER BY bucket, count DESC;

-- Slowest 50 calls in the last week, with error category
SELECT ts, tool_name, user_subject, duration_ms, success, error_category
FROM audit_events
WHERE ts >= now() - interval '7 days'
ORDER BY duration_ms DESC
LIMIT 50;

-- All failed flaky calls (for verifying retry behavior in your gateway)
SELECT ts, request_id, parameters, error_message
FROM audit_events
WHERE tool_name = 'flaky' AND NOT success
ORDER BY ts DESC;

Sanitization

audit.redact_keys is a list of case-insensitive substrings. Tool-call argument keys matching any substring have their values replaced with "[redacted]" before the row is written. The default list includes password, token, secret, authorization, cookie, api_key, credentials.

The match is on argument keys, not values. So an argument like {"password": "hunter2"} is redacted, but a free-text body that happens to contain the word "password" is not.

Sanitization is recursive: nested objects and arrays are walked.

Retention

mcp-test does not auto-prune. The audit.retention_days field documents the deployment's retention target; enforce it with a cron job:

DELETE FROM audit_events WHERE ts < now() - interval '30 days';

Performance

Each row is ~500 bytes (variable based on parameter payload) plus index overhead. At 100 calls/sec sustained, the table grows ~4GB/day plus indexes. For higher rates, partition by month or move to a dedicated audit-only Postgres instance.