Project

mcp_logs

0.0
The project is in a healthy, maintained state
Mountable Rails engine that records every Model Context Protocol request a Rails app serves and renders its tool contract as documentation.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 1.0

Runtime

>= 43.0
~> 8.0
 Project Readme

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