What to log for every MCP tool call

A client sees a remote MCP server as one opaque span, and a directory dashboard sees totals. Only the server saw the call. The row Tajo's MCP servers write for each one, field by field, what is scrubbed out of it, and what is not built.

mcp server logging

What should an MCP server write down for every tool call?

Log one row per tool call, on the server, because the server is the only place that saw it. Tajo's MCP servers record who called (the key's id, never its secret), which server and connector, the tool if it exists, its effect class, ok or a short error code, duration, result size, trace id, the records the call touched, and the arguments and answer scrubbed of secrets and personal data. Rows are read with logs.list and logs.get and deleted after 30 days. They live apart from the hash-chained audit log, where approvals and their input digests are kept. Not built: a health score, an error-rate alert, search by tool or trace id, and export to your own tracing.

Learn more
On This Page

On 8 June 2026 Anthropic gave the owners of connectors in Claude’s directory a dashboard. It shows active users, total tool calls and directory rank. It shows a “health score, error rates, and latency at a glance, with per-tool error breakdowns to pinpoint what’s failing”.

A year earlier, Alex Volkov of Weights & Biases had described the same problem from the other end. Once his agent called tools over MCP, “all that observability that I’m used to from just having my own code run end to end has gone a little bit dark”. His co-speaker, Benjamin Eckel of Dylibso, showed why. A remote MCP server “appears to us in the trace as a single service span” because it runs outside the caller’s control.

Both views stand outside the server. The client sees one opaque span. The directory sees totals. Neither saw what the server was asked, what it refused and what it touched. Only the server did, so only the server can write it down.

This is what Tajo’s MCP servers write down, read from the code that runs them and stated in the docs. What an MCP server is covers the protocol. This covers the row.

One row per call

A dashboard is the harbour master’s view: ships in, ships out, which ones were late. The request log is the ship’s own logbook, written on board at the time.

Every tools/call on Tajo’s workspace server and on every connector server ends in one row. The row is written after the response is built, whatever the response was. A call refused because the key may not write, or because the endpoint is read-only, gets a row like any other. So does a call to a tool that does not exist.

Here is what the row holds, and what it refuses to hold.

FieldKeptNot kept
WhoThe id of the API key that called. A token from an OAuth sign-in is a key too.The key’s secret or its prefix.
WhereThe workspace server or a connector server, with its connector and profile.
ToolThe name, when the server has a tool by that name.Any other name the client sends: it is stored as (unknown).
Effectread, write, send or destructive: the heaviest effect the call could have.
Outcomeok, or a short snake_case error code such as unknown_tool.Error prose: anything that is not a code is stored as tool_error.
Duration and sizeMilliseconds from arrival to response, and the bytes the caller received.
Trace idThe request’s id, from an x-request-id, x-trace-id or W3C traceparent header, else a new one.A header value that is not hex or a UUID.
RecordsUp to 20 ids of records the call named or produced: a contact, a message, an approval.The records themselves.
BodiesThe arguments and the answer, scrubbed, at most 4 KB each.Secrets, free text, and the personal data listed below.

Three of those refusals are about the client, not the data. A tool name, an error code and a trace id all arrive from outside. Each is kept only in a shape the server can vouch for. A log that stores whatever the caller sent can be filled with anything, including a customer’s email address in the tool-name column.

Benjamin Eckel’s open question at the end of the talk was how a collector knows “that it’s an MCP tool call that has an error”. On one server the answer is a column. ok is false, and the code says which error.

The arguments: a rule we reversed

The objection is fair, and it is ours. Log the arguments and you have built a second copy of your customers’ data, outside every deletion request. Until 18 October 2026 that was our own rule. The log kept neither the arguments nor the result body, because both can carry personal data.

It was the wrong trade. A request log that cannot show what was sent and what came back cannot answer the one question it exists for: why did my agent’s call fail?

So the bodies are kept, and the reason they were not is met another way. Each body goes through one deterministic pass before it is queued.

  • No secret survives. A field whose name says token, password, secret, signature, cookie, session or about twenty similar words keeps only [secret]. So does a value in a known credential shape, wherever it appears. That covers bearer tokens, JWTs, private keys, URLs with a password and any opaque run of 40 or more key characters.
  • Prose is not kept at all. A field named html, text, body, markdown or content keeps only [redacted]. Prose holds names and street addresses that no pattern finds.
  • Personal data is masked for good. A field whose name marks personal data, such as an email, a phone, a name, an address or a note, keeps only [redacted]. An email address, a phone number, a card or an IBAN inside any other string is masked.
  • Bodies stay small. Strings are cut at 512 characters, lists at 20 items, objects at 50 keys and nesting at 6 levels. The whole body is cut at 4 KB and marked truncated.

Two kinds of call keep less. Contacts a person’s chat carried in from another app are logged as a marker and a count, never a body. A call that reads the log keeps no answer, since the answer would only be other log rows.

Now the cost. The masking is permanent, not encryption with a key someone holds. The log cannot show you the email address that bounced. It shows you the contact’s id, and the contact, under its own rules, is one call away. The log sits outside the workspace erasure sweep, which is why the masking cannot wait for one.

Why the log is not the audit trail

Tajo already has an append-only audit log. Each row carries a hash of the row before it, so an edit anywhere breaks the chain. Why not write tool calls there?

Because the chain has a price. Each append takes a per-workspace lock and reads the previous row to extend the chain. Writing every read through it would line up a workspace’s calls behind one lock. It would also bury the state changes the audit log exists to show under thousands of reads.

So the two records split by question. The request log says what an agent asked and what happened, for 30 days. The audit log says what changed, who allowed it, and whether anyone has edited the record since.

Approvals sit on the audit side. On Tajo’s connector profiles, a propose tool stages a declared action and an apply tool runs it once it is approved. Nothing reaches the provider until the proposal is approved, by a person or by a rule the workspace switched on. The propose call’s row names the approval it created among its records. The approval itself is bound to a SHA-256 digest of the exact input. An approval covers the exact input you saw: change one field and it no longer matches. When the write lands, its audit row keeps the input digest and a digest of the provider’s response, never the response’s values.

What the row costs

A row is a database write, and a write has a commit. Until 8 October 2026 each call waited for its own commit before the response, about 2.6 ms on our host. For one client, that was a quarter of a small tool call.

Now the row is built when the call ends and queued. A flush writes the queue at most 100 ms later, one transaction per workspace, so many calls share one commit. Nothing about a row changes, including its timestamp. If 5,000 rows are waiting, the next call waits for the flush rather than dropping a row.

Recording never fails a call. A write that fails is retried row by row, then logged and dropped. What a hard kill of the process loses is whatever sat in the queue: at most the last 100 ms of calls.

Reading it back

logs.list returns the workspace’s calls, newest first, without bodies. It filters by time, by outcome or HTTP status, by source (MCP or the REST API, which shares the table) and by API key. It pages 50 at a time by default, up to 100. logs.get returns one call with the key’s current name, its status, its duration, the scrubbed request and response, and the records it touched.

Both read only the caller’s workspace. A row id from another workspace is not found.

A tool error carries the request id. When the server made that id, or it arrived as hex or a UUID, it is the row’s trace id. An agent that got an error can hand a person the one value that names the call.

What is built, and what is not

Built, as of this writing:

Not built, and worth knowing before you rely on it:

  • No health score, no per-tool chart and no alert on a rising error rate. A per-tool summary of calls, errors and slowest durations exists for Tajo’s operators, not on any surface you can open.
  • logs.list cannot filter by tool name or by trace id. To find the row behind a request id, narrow by time and key.
  • Rows are not exported to a tracing system you run. The trace id is read from HTTP headers only, not from the MCP _meta field the talk used to carry trace context.
  • After 30 days a row is deleted. There is no archive or export.
  • What was masked cannot be recovered from the log.
  • A hard kill can lose the last 100 ms of rows.

The talk closed on observability in MCP being “not evenly distributed”. A dashboard downstream cannot fix that, and neither can a trace that stops at the server’s door. A sync that fails needs its own record; so does every call that asked for one.

The server saw the call. Nobody else did. Write it down there.

Frequently Asked Questions

What should an MCP server log for each tool call?

One row per call, written by the server: which API key called, which server and connector, the tool name if the tool exists, its effect class, ok or a short error code, the duration, the result size, the request's trace id, the ids of the records the call named or produced, and the arguments and answer with secrets and personal data scrubbed out. Tajo's MCP servers write that row for every tools/call, refused calls included, and keep it 30 days.

Should an MCP request log keep the tool arguments?

Yes, scrubbed. A log without the arguments cannot say why a call failed. Tajo kept no bodies until 18 October 2026 and now keeps both, after one deterministic pass: secrets become [secret], free text and personal fields become [redacted], emails, phones, cards and IBANs are masked, and each body is cut at 4 KB. The masking is permanent, so the log points to the record by id instead of copying it.

Why keep the MCP tool-call log apart from the audit log?

Because they answer different questions at different costs. Tajo's audit log is a hash chain of state changes, and each append takes a per-workspace lock to extend the chain. Writing every read through it would queue a workspace's calls behind that lock and bury the changes among the reads. Tool calls go to a separate, unchained table kept 30 days; the writes a call stages still leave their own audit rows.

Request early access

Enter your first name and email address. We will email you a link to confirm your request for Tajo access.

Get Brevo

This page as Markdown: what-to-log-for-every-mcp-tool-call.md

Loading Markdown…