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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Audit rows in Postgres or D1 | 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 | 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 | 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 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.
Related how-tos
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
-
The same conventions recommend
mcp.session.idwhenever 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 theMcp-Session-Idheader, 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 -
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