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. Whenfalse,McpLogs.around_requestcalls the handler straight through and nothing is written.server— a lambda returning yourMCP::Server. Only the docs page calls it; capture never builds or touches a server.base_controller_class— required. A String, constantized on first autoload of the engine'sApplicationController(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 raisesMcpLogs::ConfigurationErrorrather 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— whenfalse,argumentsandresponseare 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*_truncatedcolumn is set.payload_filter— a lambda run on every payload after Rails'filter_parameters, before the size cap. Use it for redactionfilter_parametersdoesn't already cover. It is also the way to stop storing a payload you don't want kept — returnniland the column stays empty. The usual candidate is thetools/listresult: 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 backMcpLogs::Call.purge!,McpLogs::PurgeJobandrake mcp_logs:purgereach when called with no explicit argument. Default 30.abandoned_after_hours— how long a call may sit inrunningbeforeMcpLogs::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 fromconfig.server, grouped byconfig.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/responsecolumns arejsonb) - An asset pipeline — Propshaft or
sprockets-rails. The pages are styled by the engine's ownmcp_logs.css, and the engine shipsapp/assets/config/mcp_logs_manifest.jsdeclaring 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
mcpgem, supplied by the host —mcp_logsonly depends onrailsandpagy