McpLogs: Seeing What the Agent Actually Called
A month ago I wrote about LlmLogs, a Rails engine for tracing LLM calls my app makes. Since then I’ve been on the other side of that arrow: my apps increasingly serve an MCP endpoint, and the traffic arriving there comes from an agent — Claude Code, Claude Desktop, whatever someone pointed at it.
The debugging problem inverts, but it doesn’t get easier. When an agent misuses a tool, the app usually did exactly what it was told. The question is what it was told:
- Which tool did the agent call, with what arguments?
- Did the call fail, or did it succeed and return an error the agent ignored?
- Is that call still running, or did it die?
- Which client was this — and is it still connected under a session id?
- How often does anyone actually call the tool I spent a day writing?
So I built McpLogs: a mountable Rails engine that records every MCP request your app serves, and renders your tool contract as documentation read live from the server object.
What it records
Capture hangs off the around_request hook the mcp gem already exposes,
plus one wrapper in the controller that serves requests:
# wherever you build the server
MCP::Server.new(..., configuration: MCP::Configuration.new(
around_request: McpLogs.around_request(server_name: "my-app")
))
# in the controller that serves it
McpLogs.with_context(request) { server.handle_json(request.body.read) }
Each JSON-RPC request becomes one row: method, tool name, arguments, response,
status, duration, client name and version, protocol version, session id, IP,
user agent. Payloads go through Rails’ filter_parameters, then a filter
lambda of your own, then a size cap.
Two details I care about more than the column list.
A row is written when the call starts, not when it finishes. It goes in as
running and gets updated on completion. That means a tool call that takes
forty seconds is visible for those forty seconds — you can open the page and
see what the agent is doing right now, which is exactly when you want to look.
Tool errors are not transport errors. A tool returning isError: true is a
perfectly successful JSON-RPC response carrying a domain failure — a rejected
pattern, an already-resolved record. That gets its own tool_error flag rather
than being folded into status, because “the tool said no” and “the request
blew up” are different problems and you filter for them at different times.
Long calls and dead calls look identical
Inserting a running row up front buys visibility, and it costs something. If
the completing update never lands — the container restarts mid-call for a
deploy, or the log write itself fails — nothing retries it. Left alone that row
claims to be running for the whole retention window, and an operator asking
“what is the agent doing right now” gets a permanent false positive.
McpLogs::Call.sweep_abandoned! settles them: anything still running past
config.abandoned_after_hours becomes an error with error_type: "abandoned".
completed_at and duration_ms stay nil, because the sweep knows the call
never finished, not when it stopped. It runs inside McpLogs::PurgeJob and
rake mcp_logs:purge, so scheduling retention covers this too.
The setting matters: until the sweep runs, a long call and a dead one are genuinely indistinguishable. The recorder can’t tell them apart and neither can the page. So the threshold sits above your slowest tool and the ambiguity is resolved by waiting, which is the honest way to resolve it.
Docs read from the server, not from a file
The second page is the one I didn’t plan to build. Your tool contract already
exists — the agent fetches it on every connect via tools/list. Writing it
down a second time in a README just creates a copy that drifts.
So the docs page reads MCP::Tool.to_h, the exact payload tools/list
returns, and renders it for a human: every tool, every parameter, its type,
whether it’s required, its enum values. Grouped into sections you name in the
initializer, with anything unlisted landing under “Ungrouped” so an omission is
visible rather than silent.
Each tool shows its call count, linking back into the filtered log. That pairing turned out to be the useful part: the contract you published next to the evidence of whether anyone uses it.
The log has no authentication, on purpose
config.base_controller_class is required and has no default. The engine’s
controllers descend from a controller of yours and inherit whatever it
enforces:
config.base_controller_class = "Admin::BaseController"
The generated initializer ships this line uncommented as "CHANGE_ME", and
CHANGE_ME raises on the first request to /mcp_logs. That’s deliberate
friction. These pages render every argument and every response your MCP server
has ever seen, so an app should not be able to reach production having skipped
this by accident. Shipping a default that happens to work would have made
forgetting silent.
What the mcp gem doesn’t let me see
Two things I couldn’t fix from inside the engine, and one requirement I have to push onto the host.
Cancelled requests record nothing. notifications/cancelled returns before
instrument_call runs, so the hook is never called and there’s no row.
Unhandled notifications lose their method name. They arrive as the literal
string "unsupported_method" rather than what the client actually sent. So an
unsupported_method row means “some notification this server has no handler
for”, not a client calling a method by that name.
Build a fresh MCP::Server per request. In mcp 1.0.0 the hash
around_request writes into — tool name, arguments, error — isn’t per-request.
It’s an instance variable on the server object, reassigned to {} at the top
of every instrument_call. Share one server across concurrent requests and
request B’s reassignment can land while A is still mid-handler, so A’s later
writes end up in B’s hash. The recorder holds the reference it’s handed and
trusts it describes the request in progress; it has no way to detect the
crossover. Memoize a server and you get logged tool names and arguments quietly
attributed to the wrong call.
That last one is the kind of thing you only find by reading the gem’s source, and I’d rather it be written down here than discovered by someone staring at a log that’s subtly lying to them.
Why an engine again
Same answer as LlmLogs. The data belongs next to the app: it’s your tools, your records, your auth boundary, your Postgres, your background jobs. A mountable engine reuses all of it. No service to run, no dashboard to secure separately, no vendor between me and a question about my own server.
gem "mcp_logs"
rails generate mcp_logs:install
rails db:migrate
The installer writes the initializer, mounts the engine at /mcp_logs, and
copies the migration. Requires Ruby 3.3+, Rails 8, and PostgreSQL — the
arguments and response columns are jsonb. The mcp gem itself is a
development dependency, not a runtime one, so the engine installs and renders
fine in an app that doesn’t serve MCP yet.
It’s version 0.1.0 and it’s small. But between LlmLogs and McpLogs I can now see both directions of the conversation: what my app asks a model, and what an agent asks my app.