# How to record an audit trail of every MCP tool call

> Write one record per tool call with the caller's identity, a keyed digest of the arguments, and the outcome, then ask the store which agent deleted a record.

Source: https://voxgig.com/howto/audit-trail-for-mcp-tool-calls

- Audience: platform-team
- Level: intermediate
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Wrap every tool handler so it writes one record before the call runs and completes it after. Record the identity the token resolved to, the tool, an allowlisted subject such as the record id, an HMAC of the canonical arguments, the result size, the duration, and the outcome. Never store the raw arguments. Send the record to a log line, an OpenTelemetry span, or a database row, and query the row to find who deleted what.

---
## You will need

Node 22 or later, an MCP server that resolves each bearer token to an identity, and a secret key
for the argument digest. Verified 2026-10-15 against Node 22.22.0, `@modelcontextprotocol/server`
and `@modelcontextprotocol/client` 2.3.1, and `@opentelemetry/sdk-trace-base` 2.12.0. Resolving
the token is the authorization step of the
[MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
and a how-to of its own; this page starts from the identity it produces. The database is
[`node:sqlite`](https://nodejs.org/api/sqlite.html), standing in for D1, which speaks the same SQL.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Audit rows in Postgres or D1](https://developers.cloudflare.com/d1/worker-api/prepared-statements/) | You must answer who did what to which record, by query, months later | A database write on every call, and a policy for a write that fails | Call volume makes a write per call your bottleneck |
| [OpenTelemetry spans with the GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md) | You trace already and want the tool call joined to the upstream calls it made | A collector and a backend to run, and sampling can drop the call you need | Sampling is on, or trace retention is shorter than your audit window |
| [Structured JSON logs with Workers Logpush or Loki](https://developers.cloudflare.com/workers/observability/logs/logpush/) | You ship logs somewhere already and want the record nearly free | Record-level questions become filtered scans over a time range you have to guess | You need to answer which agent touched one record, quickly |

The log line costs almost nothing to emit and is awkward to ask questions of, because
[Loki](https://grafana.com/docs/loki/latest/get-started/labels/) indexes labels and not the
content of each line. The span joins
the tool call to the upstream API calls inside it, and needs a collector to get anywhere. The row
answers the deletion question in one query and adds a write to the hot path. The record below is
the same in all three, so you can send it to more than one.

## Keep arguments out of the record

Raw arguments are the pitfall. Users paste keys and phone numbers into free-text fields, and an
audit trail that stores arguments stores those. Keep two things instead: the fields you named as
safe, each only when its value has the shape you named for it, and a keyed digest of everything.

```ts title="audit.mjs"
// Keyed, so nobody holding the trail can confirm a guessed argument by hashing it themselves.
export function argsDigest(key, args) {
  return `hmac-sha256:${createHmac('sha256', key).update(canonicalJson(args)).digest('hex')}`
}

// An allowlist of shapes: a top-level argument is kept only when its name is listed and its whole
// value matches the pattern listed with it. The caller writes the value, so a key pasted into
// meter_id is still a key, and the name alone would keep it.
export function clearArgs(args, clearShapes) {
  const kept = {}
  for (const [field, shape] of Object.entries(clearShapes)) {
    if (!(shape instanceof RegExp)) throw new TypeError(`audit: the shape for ${field} must be a RegExp`)
    // Anchored here, so a pattern written without ^ and $, or with |, still has to match all of it.
    const whole = new RegExp(`^(?:${shape.source})$`, shape.flags.replace(/[gmy]/g, ''))
    const value = args[field]
    const text = typeof value === 'number' || typeof value === 'boolean' ? String(value) : value
    if (typeof text === 'string' && text.length <= 64 && whole.test(text)) kept[field] = value
  }
  return kept
}
```

The digest lets you prove later that a given call was the one made, without the trail holding
what it contained. It is an [HMAC](https://nodejs.org/api/crypto.html) over JSON with sorted keys,
so the same arguments in a different order give the same value. A plain hash would not do, and
the pitfall section shows why.

## Write the record before the call runs

The wrapper inserts the record with outcome `started` before the handler runs, then completes it.
A process that dies halfway through a delete still leaves a row saying who began it.

```ts title="audit.mjs"
export function withAudit(toolName, { clear = {}, subject }, handler, { sinks, key, clock = Date.now }) {
  return async (args, ctx) => {
    const started = clock()
    const record = {
      at: new Date(started).toISOString(),
      // The identity the token resolved to. The client name is what the caller says it is.
      identity: ctx.http?.authInfo?.clientId ?? 'anonymous',
      client: String(ctx.mcpReq?.envelope?.['io.modelcontextprotocol/clientInfo']?.name ?? 'unknown').slice(0, 64),
      tool: toolName,
      subject: String(clearArgs(args, clear)[subject] ?? ''),
      args: clearArgs(args, clear),
      argsDigest: argsDigest(key, args),
      outcome: 'started',
      resultBytes: 0,
      durationMs: 0,
    }
```

The record holds two identities, of which only the first is evidence. `identity` comes from the
token, via `ctx.http.authInfo`. `client` is the `clientInfo` the 2026-07-28 revision asks every
request to carry, and the caller writes it. A test pins a `billing-bot` token whose client calls
itself `ops-bot`.

A failed insert stops the call before it runs: no row, no delete. A failed update after the call
is reported and swallowed, because the side effect has already happened and an error would tell
the agent the delete failed.

## Send one record to three sinks

The span sink follows the
[MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md),
which are in Development status: a `SERVER` span named `tools/call` and the tool, with
`gen_ai.operation.name` set to `execute_tool`. The conventions make `gen_ai.tool.call.arguments`
opt-in and warn that it may hold sensitive data, so the sink leaves it out.[^1]

```bash
node demo.mjs
```

```text output
log line for the note
  {"type":"mcp.tool_call","at":"2026-10-15T09:00:00.012Z","identity":"ops-bot","client":"claude-desktop","tool":"meter_note","subject":"mtr-102","args":{"meter_id":"mtr-102"},"argsDigest":"hmac-sha256:9dd90f29ea9863c97fbe5aef3f5f61907cf56b53f4f5704c9d40e68fbb660d68","outcome":"ok","resultBytes":44,"durationMs":4}

span for the first delete
  tools/call meter_delete, kind SERVER, status UNSET
  mcp.method.name        tools/call
  gen_ai.operation.name  execute_tool
  gen_ai.tool.name       meter_delete
  enduser.id             billing-bot
  audit.subject          mtr-102
  audit.args_digest      hmac-sha256:cf1d2703798ddfa18db36e97a4019683a2ee875e5efe6e3b2555aeb4a1b59648
  audit.outcome          ok
  audit.result_bytes     43

rows
  2026-10-15T09:00:00.004Z  ops-bot     claude-desktop meter_read   mtr-101 {"meter_id":"mtr-101"} ok          67 B 4 ms
  2026-10-15T09:00:00.012Z  ops-bot     claude-desktop meter_note   mtr-102 {"meter_id":"mtr-102"} ok          44 B 4 ms
  2026-10-15T09:00:00.020Z  billing-bot cursor         meter_delete mtr-102 {"meter_id":"mtr-102"} ok          43 B 4 ms
  2026-10-15T09:00:00.028Z  billing-bot cursor         meter_delete mtr-999 {"meter_id":"mtr-999"} tool_error  44 B 4 ms

which agent deleted mtr-102, and when
  billing-bot at 2026-10-15T09:00:00.020Z, through a client that calls itself cursor

secret in any sink
  sk_live_  absent
  087 555   absent
```

The note carried a fake live key and a phone number. The allowlist kept both out of the log line,
the span, and the rows. The demo's clock ticks four milliseconds per read, which is why every
duration is 4. The failed delete of `mtr-999` is a row too, with outcome `tool_error`, since an
attempt is evidence as well.

The log line is what a Worker writes with `console.log`.
[Workers Trace Events Logpush](https://developers.cloudflare.com/workers/observability/logs/logpush/)
ships those messages on the Workers Paid plan, and its documentation points new integrations at
OpenTelemetry export instead. The row is written through a prepared statement, which on D1 is
`env.DB.prepare(sql).bind(...)`.

## Check it worked

The test that matters calls a tool with a secret-looking argument, then searches every sink for
it.

```ts title="audit.test.mjs"
test('a secret-looking argument reaches no sink, while the call still succeeds', async () => {
  const h = harness()
  const served = createMeterServer({ sinks: h.sinks, key: KEY })
  const ops = await connectAs(served, { identity: 'ops-bot', clientName: 'claude-desktop' })
  const text = 'Customer key sk_live_FAKE_FOR_DOCS_0001, card 4242 4242 4242 4242'
  const result = await ops.callTool({ name: 'meter_note', arguments: { meter_id: 'mtr-102', text } })
  assert.notEqual(result.isError, true)
  assert.equal(served.notes[0].text, text, 'the tool itself received the argument unchanged')
  for (const needle of ['sk_live_', '4242 4242']) assert.doesNotMatch(h.everything(), new RegExp(needle))
  const [row] = h.rows.db.prepare('SELECT args, args_digest FROM mcp_audit').all()
  assert.equal(row.args, '{"meter_id":"mtr-102"}')
  assert.equal(row.args_digest, argsDigest(KEY, { meter_id: 'mtr-102', text }), 'the digest still identifies the exact call')
  await ops.close()
  await served.close()
})
```

```bash
node --test audit.test.mjs
```

```text output
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 502.092604
```

The other eight cover the rest. The deletion query returns the token's identity, while the client
name is whatever the caller claimed. The row exists while the handler runs, and a crash marks it
`exception`. A failed insert stops the call, and a failed update keeps the result. Long or
structured values never land in clear, even when allowlisted, and the digest ignores key order. A
fake key, bearer token, JWT, or base64 secret sent as `meter_id` fails the shape and stays out of
every sink.

## When it goes wrong

The trail still holds a key. The redaction masks arguments by name, and the secret sat in a field
called `text`:

```bash
node pitfall.mjs
```

```text output
what each redaction stores for the same call
  denylist by name    {"meter_id":"mtr-102","api_key":"[redacted]","text":"Customer key sk_live_FAKE_FOR_DOCS_0001, phone 087 555 0142"}
  allowlist by shape  {"meter_id":"mtr-102"}

the same key pasted into meter_id
  allowlist by name   {"meter_id":"sk_live_FAKE_FOR_DOCS_0001"}
  allowlist by shape  {}

recovering a meter id from its stored digest, trying mtr-000 to mtr-999
  plain sha-256       found mtr-102 after 103 guesses
  keyed hmac          not found in 1000 guesses
```

Name the fields that may be stored, not the ones that may not. A scanner for key formats would
have caught the `sk_live_` prefix and missed the phone number. A name alone is not enough either,
as the second block shows: the caller writes the value, so a key pasted into `meter_id` is still a
key. Give each allowlisted field the pattern its real values match, and keep nothing that fails
it.

The digest gives the arguments away. A SHA-256 digest with no key, over a low-entropy value, is
reversed by guessing, as the second block shows in 103 tries. Keep the HMAC key in a secret store,
apart from the trail.

An attempt left no row. The SDK checks the arguments against the tool's input schema before the
handler runs. A call it refuses, such as a delete without its `reason`, never reaches the wrapper,
as a test pins. If refused attempts matter, record them at the HTTP layer as well.

A span for the deletion is missing. The tracer sampled it out. Audit needs every call, so either
send spans for tool calls with an always-on sampler, or keep the row as the record and the span as
the context.

`node:sqlite` prints an `ExperimentalWarning` on Node 22. It goes to standard error and changes
nothing; D1 in production has no such warning.

## When not to do this

Do not store raw arguments to make debugging easier. The trail outlives the bug, and it becomes
the most interesting table in the system for anyone who gets read access.

Do not rely on the MCP client to keep the record. The specification asks clients to log tool usage
for audit purposes, and the client is the agent's software, not yours.[^2]

Do not use the audit table as the rate limiter or the authorization check. It records what
happened; a separate check decides what may happen, and coupling them means an audit outage blocks
every call.

Do not put a per-record value such as the subject in a Loki label. Loki's labels guide reserves
labels for low-cardinality values and points high-cardinality search at structured metadata,
which is where the subject belongs.

## Related how-tos

- [Choose between stdio and Streamable HTTP for an MCP server](/howto/choose-an-mcp-transport)

- [Issue and rotate per-agent API keys for an MCP server](/howto/per-agent-api-keys-for-mcp)

- [Export outbound call spans from a serverless function](/howto/flush-spans-from-a-serverless-function)

- [Stop a runaway agent loop from exhausting your MCP server](/howto/contain-a-runaway-agent-loop)

- [Require human approval before an agent calls a delete tool](/howto/require-approval-before-a-delete-tool-call)

## Last verified

Verified 2026-10-15 against Node 22.22.0, `@modelcontextprotocol/server` and
`@modelcontextprotocol/client` 2.3.1, `@opentelemetry/api` 1.9.1, `@opentelemetry/sdk-trace-base`
2.12.0, and Zod 4.6.7. Every output block is what the command preceding it printed. The secrets in
the samples are fakes, and SQLite stood in for D1.

[^1]: The same conventions recommend `mcp.session.id` whenever a request is part of a session, and
define the attribute by linking the session management section of the 2025-06-18 transport. The
2026-07-28 [revision of the protocol](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
removed protocol-level sessions and the `Mcp-Session-Id` header, so a request made under it is
part of no session and the attribute has nothing to hold. Elsewhere the conventions cite the
2025-11-25 schema, one revision behind, which Development status permits.

[^2]: The
[tools page](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#security-considerations)
of the specification lists what servers must do: validate inputs, control access, rate limit, and
sanitize outputs. Logging tool usage for audit purposes appears in the other list, among the things
clients should do. The party with the motive to keep the record and the party the specification asks
to keep it are different parties.