McpLogs

Mountable Rails engine that records every Model Context Protocol (MCP) request a Rails app serves, and renders the host's tool contract as documentation — live, from the server object itself.

Every JSON-RPC request coming through an MCP::Server is logged as it starts and completed once it finishes, so long-running tool calls are visible while they run rather than only after they return. A docs page reads the server's tools, params and descriptions directly and pairs each one with how often it's actually been called.

Installation

Add the gem, then run the installer:

# Gemfile
gem "mcp_logs"
bundle install
rails generate mcp_logs:install
rails db:migrate

The generator writes config/initializers/mcp_logs.rb, mounts the engine at /mcp_logs in config/routes.rb, and copies the mcp_logs_calls migration into your app.

Then gate the pages — this step is not optional

The generated initializer contains one line you must change:

config.base_controller_class = "CHANGE_ME"

mcp_logs ships no authentication of its own. Its controllers descend from a controller of yours and inherit whatever that controller enforces. The three pages render every argument and every response your MCP server has recorded, so until you point this at a controller that requires a signed-in operator, those pages are as reachable as the rest of your app:

config.base_controller_class = "Admin::BaseController"

There is no default, and CHANGE_ME raises on the first request to /mcp_logs, so an app cannot reach production having skipped this by accident. Set it to "ActionController::Base" only if you genuinely intend the log to be public.

mcp_logs does not depend on the mcp gem — the host supplies it. Add gem "mcp" to your own Gemfile if you haven't already.

Wiring capture

Two things need wiring: the server needs an around_request hook, and the controller that serves requests needs to hand the recorder the HTTP request.

# 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) }

Build a fresh MCP::Server per request. In mcp 1.0.0, the data an around_request hook writes into during a call — tool name, tool arguments, error — is not per-request: it's @instrumentation_data, a plain instance variable on the MCP::Server object, reassigned to {} at the top of every instrument_call. If one server instance serves two concurrent requests, request B's reassignment can land while request A is still mid-handler, so A's later writes end up in B's hash instead of A's. McpLogs::Recorder has no way to detect or correct for a shared server — it only holds the reference it's handed and trusts it describes the request in progress. Memoizing or sharing a server across requests will silently cross-contaminate logged tool names, arguments and errors between them.

Two things mcp 1.0.0 does not let this gem see

notifications/cancelled returns before instrument_call runs, so a cancelled request is never handed to around_request and records no row at all.

Every notification the server does not handle records rpc_method as the literal "unsupported_method" rather than the method the client actually sent — the gem passes that string in place of the real one. So an unsupported_method row means "some notification this server has no handler for", not a client calling a method by that name.

Neither is something the recorder can correct for; both are noted here so a row that looks wrong can be recognised as upstream behaviour.

Client identity

client_name and client_version come from the clientInfo a client sends with initialize. Under stateless Streamable HTTP, where a fresh MCP::Server is built per request, that information lives only for the duration of the initialize call — so later calls record no client. Use McpLogs.with_context(request, actor: ...) to attach an identity of your own that survives every request.

Configuration

config/initializers/mcp_logs.rb, generated by the installer:

McpLogs.setup do |config|
  # REQUIRED — the pages have no authentication except the controller you name
  # here. See "Then gate the pages" above.
  config.base_controller_class = "CHANGE_ME"

  # Master switch. When false, nothing is recorded and calls run untouched.
  # config.enabled = true

  # A lambda returning your MCP::Server. Read by the docs page only.
  # config.server = -> { Mcp::Registry.server }

  # Group the docs page by tool name. Anything unlisted renders under "Ungrouped".
  # config.tool_sections = { "Read" => %w[search_apps get_app], "Write" => %w[edit_app] }

  # Payloads are filtered through Rails' filter_parameters, then this lambda,
  # then capped at max_payload_bytes.
  # config.record_payloads = true
  # config.max_payload_bytes = 64_000
  # config.payload_filter = ->(payload) { payload }

  # Used by McpLogs::PurgeJob and `rake mcp_logs:purge`. Schedule one of them.
  # config.retention_days = 30

  # The same job settles rows stranded in "running". Set above your slowest tool.
  # config.abandoned_after_hours = 24

  # config.page_size = 50
end
  • enabled — master switch. When false, McpLogs.around_request calls the handler straight through and nothing is written.
  • server — a lambda returning your MCP::Server. Only the docs page calls it; capture never builds or touches a server.
  • base_controller_classrequired. A String, constantized on first autoload of the engine's ApplicationController (i.e. after your app's initializers have run). The engine's controllers descend from it and inherit its authentication; there is no default, and leaving it unset or unresolvable raises McpLogs::ConfigurationError rather than serving the log ungated.
  • tool_sections{ "Section title" => ["tool_name", ...] }. Tool names, not classes, so the gem never references a host constant. Anything not listed renders under "Ungrouped" on the docs page.
  • record_payloads — when false, arguments and response are never stored, regardless of size.
  • max_payload_bytes — payloads larger than this (after filtering) are replaced with a { "truncated" => true, "bytes" => ..., "preview" => ... } wrapper, and the corresponding *_truncated column is set.
  • payload_filter — a lambda run on every payload after Rails' filter_parameters, before the size cap. Use it for redaction filter_parameters doesn't already cover. It is also the way to stop storing a payload you don't want kept — return nil and the column stays empty. The usual candidate is the tools/list result: it is your entire contract, byte-identical on every client reconnect, so it produces the largest rows in the table carrying the least information. The lambda sees only the payload, so match on its shape: ->(payload) { payload.key?("tools") ? nil : payload }.
  • retention_days — how far back McpLogs::Call.purge!, McpLogs::PurgeJob and rake mcp_logs:purge reach when called with no explicit argument. Default 30.
  • abandoned_after_hours — how long a call may sit in running before McpLogs::Call.sweep_abandoned! settles it. Default 24. See "Abandoned calls" below; set it above your slowest tool call, so a genuinely long call is never mistaken for a dead one.
  • page_size — rows per page on the calls index.

What gets recorded

Each row in mcp_logs_calls is one JSON-RPC request:

Column Notes
server_name the server_name: passed to McpLogs.around_request
rpc_method the JSON-RPC method, e.g. tools/call
tool_name present for tools/call requests
arguments jsonb; filtered and size-capped
response jsonb; filtered and size-capped
status running, ok, or error
tool_error true when the tool returned isError: true — a successful JSON-RPC response carrying a tool-level failure, not a transport error
cancelled true when the request was cancelled
error_type exception class, or the gem's own error code
error_message capped at 2,000 bytes; not redacted, since exception messages routinely echo attribute values or an entire failing SQL statement
duration_ms wall time of the handler itself
client_name, client_version from the client's initialize clientInfo — see "Client identity" above
protocol_version, session_id from the Mcp-Protocol-Version / Mcp-Session-Id headers
actor, ip, user_agent from the request, or from McpLogs.with_context's actor: option
arguments_truncated, response_truncated, error_message_truncated one flag per payload, set independently, so the detail page can say exactly which payload was capped
started_at, completed_at a row is inserted as running at start and updated at finish, so a long call is visible while it's in flight

Payloads are always run through Rails' filter_parameters, then config.payload_filter, then capped at config.max_payload_bytes before being stored.

Every string column above is varchar(255) and truncated to match at capture time. tool_name in particular is not validated input — mcp 1.0.0 reads it from params.name and hands it to the hook before looking the tool up, so an unknown tool records whatever the client sent — and session_id, protocol_version and user_agent are raw request headers. The calls page's filter dropdowns are bounded for the same reason.

Pages

Mounted at /mcp_logs (or wherever you routed it):

  • Calls (/mcp_logs) — paginated index of every logged call, filterable by method, tool, status and session.
  • Call detail (/mcp_logs/calls/:id) — one call's full arguments, response, error and timing.
  • Docs (/mcp_logs/docs) — the host's tool contract, read live from config.server, grouped by config.tool_sections, with each tool's call count linking back to the calls index.

Abandoned calls

A row is inserted running when a request starts and updated when it finishes. If that update never lands — the container restarts mid-call for a deploy, or the log write itself fails — nothing retries it. Left alone, the row would claim to be running for the whole retention window, so an operator asking "what is the agent doing right now" gets a permanent false positive.

McpLogs::Call.sweep_abandoned! settles those rows: anything still running past config.abandoned_after_hours becomes status: "error" with error_type: "abandoned" and an error_message saying no completion was ever recorded. completed_at and duration_ms stay nil — the sweep knows the call did not finish, not when it stopped.

It runs as part of McpLogs::PurgeJob and rake mcp_logs:purge below, so scheduling either one covers both. Set abandoned_after_hours above your slowest tool call: until the sweep runs, a long call and a dead one are genuinely indistinguishable — the recorder cannot tell them apart, and neither can the page.

Retention

Logged calls accumulate; nothing prunes them automatically. Two ways to run McpLogs::Call.purge!(McpLogs.retention_days) and McpLogs::Call.sweep_abandoned! on a schedule:

McpLogs::PurgeJob.perform_later
rake mcp_logs:purge

With solid_queue's config/recurring.yml:

mcp_logs_purge:
  class: McpLogs::PurgeJob
  schedule: every day at 3am

Requirements

  • Ruby >= 3.3
  • Rails ~> 8.0
  • PostgreSQL (the arguments/response columns are jsonb)
  • An asset pipeline — Propshaft or sprockets-rails. The pages are styled by the engine's own mcp_logs.css, and the engine ships app/assets/config/mcp_logs_manifest.js declaring it, which is what Sprockets needs to precompile an engine asset. Nothing to configure either way; without a pipeline at all, the pages render unstyled.
  • The mcp gem, supplied by the host — mcp_logs only depends on rails and pagy