How-to › Expose your API to agents

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.

Audience
Platform team
Level
intermediate
Topic
Host, transport and secure an MCP server
Languages
TypeScript
Verified

A meter disappears from the table on Tuesday, and three agents called your MCP server that day. Your logs hold each call’s raw arguments, including an API key a user pasted into a note, but no identity the token resolved to. You cannot say which agent deleted the meter, and the log has become a store of secrets.

What you get

You will end up with one durable record per tool call, a query that says which agent deleted a record and when, and a test proving a secret argument reaches no sink. This is for you if you run an MCP server other people’s agents call.

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 and a how-to of its own; this page starts from the identity it produces. The database is node:sqlite, standing in for D1, which speaks the same SQL.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Audit rows in Postgres or D1You must answer who did what to which record, by query, months laterA database write on every call, and a policy for a write that failsCall volume makes a write per call your bottleneck
OpenTelemetry spans with the GenAI semantic conventionsYou trace already and want the tool call joined to the upstream calls it madeA collector and a backend to run, and sampling can drop the call you needSampling is on, or trace retention is shorter than your audit window
Structured JSON logs with Workers Logpush or LokiYou ship logs somewhere already and want the record nearly freeRecord-level questions become filtered scans over a time range you have to guessYou 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 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.

// 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 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.

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, 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

node demo.mjs
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 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.

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()
})
node --test audit.test.mjs
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:

node pitfall.mjs
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.

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.

Footnotes

  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 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. ↩︎ Back to text

  2. The tools page 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.