Skip to content

Audit log

The server writes an audit entry for every tool call, and for every request it refuses. The log shows who asked for what and when, which tables run_select was pointed at, and any sign of misuse, such as refused sign-ins or queries against tables that aren’t allowed.

The log is in JSON Lines format: one JSON object per line, easy to search and to send to a central logging system. This entry, shown over several lines here, records a run_select call:

One entry in audit.jsonl
{
"time": "2026-09-24T14:32:10.482Z",
"user": "[email protected]",
"address": "10.20.4.17",
"client": { "name": "Visual Studio Code", "version": "1.104.0" },
"tool": "run_select",
"arguments": {
"sql": "SELECT COUNT(*) FROM PS_ZZ_TRAIN_REQ",
"max_rows": 100
},
"outcome": "ok",
"rows": 1,
"truncated": false,
"durationMs": 57,
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
Field Description
time When the request arrived, in UTC
user Who made it. On a shared server, the identity in the person’s access token; locally, see Local logs
address On a shared server, the IP address of the person’s machine, as nginx reports it
client The IDE’s name and version, as it reports them in the request
tool, arguments The tool and its arguments, exactly as sent
outcome ok, error, denied, or rate_limited (see below)
error For any outcome but ok, the error message
rows, truncated How many rows the database returned, and whether the result was cut short
removedCharacters How many hidden characters were removed from the result, when there were any
durationMs How long the call took
traceId The OpenTelemetry trace the call belongs to, when the IDE sent trace context, so you can follow it from the IDE to the SQL

The outcome says what happened:

Outcome Meaning
ok The tool ran and returned a result
error The tool returned an error, such as a name that doesn’t exist, a run_select query that broke a rule, or a query timeout
denied The request was refused before any tool ran, because it had no valid access token, lacked a scope, or came from an origin that isn’t allowed
rate_limited The person had used up their calls for the minute
  • Results. The log records how many rows came back, never the rows themselves, so it doesn’t become a copy of your data.
  • Credentials. Access tokens and the database password never appear in the log.

The arguments are recorded in full, though, including the SQL of every run_select call and the text of every search. Protect the log accordingly: restrict who can read it, and keep it only as long as your policies require.

On a shared server, the log is a file, such as /var/log/aipeoplesoft/audit.jsonl, on a volume that outlives the container, as set up in Run a shared server. Send it to your central logging system with that system’s file collector, and rotate it with your usual tools, such as logrotate.

To record each person’s address, nginx passes it to the server in the X-Forwarded-For header. The server trusts that header only from nginx, on the loopback interface.

A local server writes the log to its standard error by default. The MCP specification lets IDEs capture that output or ignore it, so to keep a local log, set audit.destination to a file on a mounted volume.

Inside its container, a local server can’t see who you are. It records the name in the AIPS_USER environment variable, if the IDE’s configuration sets it, and local otherwise. Anyone can set that name, so a local log shows what was done on a machine, not who did it. For an audit trail you can rely on, use a shared server, where the identity comes from each person’s sign-in.

  • Refused requests. Many denied entries from one address suggest someone probing the server.
  • Tables that aren’t allowed. A run_select error naming a table that isn’t on the allow-list means someone, or an injected instruction, went looking for data.
  • Hidden characters. A removedCharacters count means your code or data holds invisible text. Find out where it came from; see Prompt injection.
  • Unusual volume. Rate limiting, or far more calls than usual from one person, can mean a runaway agent or scripted use.

jq makes quick work of these checks:

Terminal window
# Every refused request
jq -c 'select(.outcome == "denied")' audit.jsonl
# Each run_select call: when, who, and the SQL
jq -r 'select(.tool == "run_select")
| [.time, .user, .arguments.sql] | @tsv' audit.jsonl
# Results that had hidden characters removed
jq -c 'select(.removedCharacters > 0)' audit.jsonl