Anton Kopylov

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.

← Blog