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.
An entry
Section titled “An entry”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:
{ "time": "2026-09-24T14:32:10.482Z", "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 |
What isn’t recorded
Section titled “What isn’t recorded”- 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.
Where the log goes
Section titled “Where the log goes”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.
Local logs
Section titled “Local logs”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.
What to watch for
Section titled “What to watch for”- Refused requests. Many
deniedentries from one address suggest someone probing the server. - Tables that aren’t allowed. A
run_selecterror naming a table that isn’t on the allow-list means someone, or an injected instruction, went looking for data. - Hidden characters. A
removedCharacterscount 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:
# Every refused requestjq -c 'select(.outcome == "denied")' audit.jsonl
# Each run_select call: when, who, and the SQLjq -r 'select(.tool == "run_select") | [.time, .user, .arguments.sql] | @tsv' audit.jsonl
# Results that had hidden characters removedjq -c 'select(.removedCharacters > 0)' audit.jsonl