mcp_logs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1a15a9653e6f24c9989d2a82579dbb31f50a5d2f49e3b164cad181135a0eb3b7
4
+ data.tar.gz: f150b3e7c2bc5df9ecd58a883354ca4c5d77748757e40adadeb975114f18ab2f
5
+ SHA512:
6
+ metadata.gz: caf9e8db40d29ba9deeec6a8f36125421d0034b7fa7fbce203ce9ab66d6b10a34981c82be56017b04468ff56c701d400ce50d3b5525630dab0a12767212bd33a
7
+ data.tar.gz: d87d082c24baf8257eb3c5e944373b0ed350fee4aa25687ab5f990652692cf99a85ffd1ce17e7710f51c8e6ac8b0e80a2fbb78dec7a9e0e02772f2a379e437d9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # McpLogs
2
+
3
+ Mountable Rails engine that records every Model Context Protocol (MCP) request
4
+ a Rails app serves, and renders the host's tool contract as documentation —
5
+ live, from the server object itself.
6
+
7
+ Every JSON-RPC request coming through an `MCP::Server` is logged as it starts
8
+ and completed once it finishes, so long-running tool calls are visible while
9
+ they run rather than only after they return. A docs page reads the server's
10
+ tools, params and descriptions directly and pairs each one with how often it's
11
+ actually been called.
12
+
13
+ ## Installation
14
+
15
+ Add the gem, then run the installer:
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ gem "mcp_logs"
20
+ ```
21
+
22
+ ```sh
23
+ bundle install
24
+ rails generate mcp_logs:install
25
+ rails db:migrate
26
+ ```
27
+
28
+ The generator writes `config/initializers/mcp_logs.rb`, mounts the engine at
29
+ `/mcp_logs` in `config/routes.rb`, and copies the `mcp_logs_calls` migration
30
+ into your app.
31
+
32
+ ### Then gate the pages — this step is not optional
33
+
34
+ The generated initializer contains one line you must change:
35
+
36
+ ```ruby
37
+ config.base_controller_class = "CHANGE_ME"
38
+ ```
39
+
40
+ `mcp_logs` ships **no authentication of its own**. Its controllers descend from
41
+ a controller of yours and inherit whatever that controller enforces. The three
42
+ pages render every argument and every response your MCP server has recorded, so
43
+ until you point this at a controller that requires a signed-in operator, those
44
+ pages are as reachable as the rest of your app:
45
+
46
+ ```ruby
47
+ config.base_controller_class = "Admin::BaseController"
48
+ ```
49
+
50
+ There is no default, and `CHANGE_ME` raises on the first request to `/mcp_logs`,
51
+ so an app cannot reach production having skipped this by accident. Set it to
52
+ `"ActionController::Base"` only if you genuinely intend the log to be public.
53
+
54
+ `mcp_logs` does not depend on the `mcp` gem — the host supplies it. Add
55
+ `gem "mcp"` to your own Gemfile if you haven't already.
56
+
57
+ ## Wiring capture
58
+
59
+ Two things need wiring: the server needs an `around_request` hook, and the
60
+ controller that serves requests needs to hand the recorder the HTTP request.
61
+
62
+ ```ruby
63
+ # wherever you build the server
64
+ MCP::Server.new(..., configuration: MCP::Configuration.new(
65
+ around_request: McpLogs.around_request(server_name: "my-app")
66
+ ))
67
+
68
+ # in the controller that serves it
69
+ McpLogs.with_context(request) { server.handle_json(request.body.read) }
70
+ ```
71
+
72
+ **Build a fresh `MCP::Server` per request.** In `mcp` 1.0.0, the data an
73
+ `around_request` hook writes into during a call — tool name, tool arguments,
74
+ error — is not per-request: it's `@instrumentation_data`, a plain instance
75
+ variable on the `MCP::Server` object, reassigned to `{}` at the top of every
76
+ `instrument_call`. If one server instance serves two concurrent requests,
77
+ request B's reassignment can land while request A is still mid-handler, so
78
+ A's later writes end up in B's hash instead of A's. `McpLogs::Recorder` has no
79
+ way to detect or correct for a shared server — it only holds the reference
80
+ it's handed and trusts it describes the request in progress. Memoizing or
81
+ sharing a server across requests will silently cross-contaminate logged tool
82
+ names, arguments and errors between them.
83
+
84
+ ### Two things `mcp` 1.0.0 does not let this gem see
85
+
86
+ `notifications/cancelled` returns before `instrument_call` runs, so a cancelled
87
+ request is never handed to `around_request` and records no row at all.
88
+
89
+ Every notification the server does not handle records `rpc_method` as the
90
+ literal `"unsupported_method"` rather than the method the client actually sent —
91
+ the gem passes that string in place of the real one. So an `unsupported_method`
92
+ row means "some notification this server has no handler for", not a client
93
+ calling a method by that name.
94
+
95
+ Neither is something the recorder can correct for; both are noted here so a row
96
+ that looks wrong can be recognised as upstream behaviour.
97
+
98
+ ### Client identity
99
+
100
+ `client_name` and `client_version` come from the `clientInfo` a client sends with
101
+ `initialize`. Under stateless Streamable HTTP, where a fresh `MCP::Server` is built
102
+ per request, that information lives only for the duration of the `initialize` call —
103
+ so later calls record no client. Use `McpLogs.with_context(request, actor: ...)` to
104
+ attach an identity of your own that survives every request.
105
+
106
+ ## Configuration
107
+
108
+ `config/initializers/mcp_logs.rb`, generated by the installer:
109
+
110
+ ```ruby
111
+ McpLogs.setup do |config|
112
+ # REQUIRED — the pages have no authentication except the controller you name
113
+ # here. See "Then gate the pages" above.
114
+ config.base_controller_class = "CHANGE_ME"
115
+
116
+ # Master switch. When false, nothing is recorded and calls run untouched.
117
+ # config.enabled = true
118
+
119
+ # A lambda returning your MCP::Server. Read by the docs page only.
120
+ # config.server = -> { Mcp::Registry.server }
121
+
122
+ # Group the docs page by tool name. Anything unlisted renders under "Ungrouped".
123
+ # config.tool_sections = { "Read" => %w[search_apps get_app], "Write" => %w[edit_app] }
124
+
125
+ # Payloads are filtered through Rails' filter_parameters, then this lambda,
126
+ # then capped at max_payload_bytes.
127
+ # config.record_payloads = true
128
+ # config.max_payload_bytes = 64_000
129
+ # config.payload_filter = ->(payload) { payload }
130
+
131
+ # Used by McpLogs::PurgeJob and `rake mcp_logs:purge`. Schedule one of them.
132
+ # config.retention_days = 30
133
+
134
+ # The same job settles rows stranded in "running". Set above your slowest tool.
135
+ # config.abandoned_after_hours = 24
136
+
137
+ # config.page_size = 50
138
+ end
139
+ ```
140
+
141
+ - **`enabled`** — master switch. When `false`, `McpLogs.around_request` calls
142
+ the handler straight through and nothing is written.
143
+ - **`server`** — a lambda returning your `MCP::Server`. Only the docs page
144
+ calls it; capture never builds or touches a server.
145
+ - **`base_controller_class`** — **required.** A String, constantized on first
146
+ autoload of the engine's `ApplicationController` (i.e. after your app's
147
+ initializers have run). The engine's controllers descend from it and inherit
148
+ its authentication; there is no default, and leaving it unset or unresolvable
149
+ raises `McpLogs::ConfigurationError` rather than serving the log ungated.
150
+ - **`tool_sections`** — `{ "Section title" => ["tool_name", ...] }`. Tool
151
+ names, not classes, so the gem never references a host constant. Anything
152
+ not listed renders under "Ungrouped" on the docs page.
153
+ - **`record_payloads`** — when `false`, `arguments` and `response` are never
154
+ stored, regardless of size.
155
+ - **`max_payload_bytes`** — payloads larger than this (after filtering) are
156
+ replaced with a `{ "truncated" => true, "bytes" => ..., "preview" => ... }`
157
+ wrapper, and the corresponding `*_truncated` column is set.
158
+ - **`payload_filter`** — a lambda run on every payload after Rails'
159
+ `filter_parameters`, before the size cap. Use it for redaction
160
+ `filter_parameters` doesn't already cover. It is also the way to stop storing
161
+ a payload you don't want kept — return `nil` and the column stays empty. The
162
+ usual candidate is the `tools/list` result: it is your entire contract,
163
+ byte-identical on every client reconnect, so it produces the largest rows in
164
+ the table carrying the least information. The lambda sees only the payload, so
165
+ match on its shape: `->(payload) { payload.key?("tools") ? nil : payload }`.
166
+ - **`retention_days`** — how far back `McpLogs::Call.purge!`,
167
+ `McpLogs::PurgeJob` and `rake mcp_logs:purge` reach when called with no
168
+ explicit argument. Default 30.
169
+ - **`abandoned_after_hours`** — how long a call may sit in `running` before
170
+ `McpLogs::Call.sweep_abandoned!` settles it. Default 24. See "Abandoned
171
+ calls" below; set it above your slowest tool call, so a genuinely long call
172
+ is never mistaken for a dead one.
173
+ - **`page_size`** — rows per page on the calls index.
174
+
175
+ ## What gets recorded
176
+
177
+ Each row in `mcp_logs_calls` is one JSON-RPC request:
178
+
179
+ | Column | Notes |
180
+ | --- | --- |
181
+ | `server_name` | the `server_name:` passed to `McpLogs.around_request` |
182
+ | `rpc_method` | the JSON-RPC method, e.g. `tools/call` |
183
+ | `tool_name` | present for `tools/call` requests |
184
+ | `arguments` | jsonb; filtered and size-capped |
185
+ | `response` | jsonb; filtered and size-capped |
186
+ | `status` | `running`, `ok`, or `error` |
187
+ | `tool_error` | true when the tool returned `isError: true` — a successful JSON-RPC response carrying a tool-level failure, not a transport error |
188
+ | `cancelled` | true when the request was cancelled |
189
+ | `error_type` | exception class, or the gem's own error code |
190
+ | `error_message` | capped at 2,000 bytes; not redacted, since exception messages routinely echo attribute values or an entire failing SQL statement |
191
+ | `duration_ms` | wall time of the handler itself |
192
+ | `client_name`, `client_version` | from the client's `initialize` `clientInfo` — see "Client identity" above |
193
+ | `protocol_version`, `session_id` | from the `Mcp-Protocol-Version` / `Mcp-Session-Id` headers |
194
+ | `actor`, `ip`, `user_agent` | from the request, or from `McpLogs.with_context`'s `actor:` option |
195
+ | `arguments_truncated`, `response_truncated`, `error_message_truncated` | one flag per payload, set independently, so the detail page can say exactly which payload was capped |
196
+ | `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 |
197
+
198
+ Payloads are always run through Rails' `filter_parameters`, then
199
+ `config.payload_filter`, then capped at `config.max_payload_bytes` before
200
+ being stored.
201
+
202
+ Every string column above is `varchar(255)` and truncated to match at capture
203
+ time. `tool_name` in particular is not validated input — `mcp` 1.0.0 reads it
204
+ from `params.name` and hands it to the hook *before* looking the tool up, so an
205
+ unknown tool records whatever the client sent — and `session_id`,
206
+ `protocol_version` and `user_agent` are raw request headers. The calls page's
207
+ filter dropdowns are bounded for the same reason.
208
+
209
+ ## Pages
210
+
211
+ Mounted at `/mcp_logs` (or wherever you routed it):
212
+
213
+ - **Calls** (`/mcp_logs`) — paginated index of every logged call, filterable
214
+ by method, tool, status and session.
215
+ - **Call detail** (`/mcp_logs/calls/:id`) — one call's full arguments,
216
+ response, error and timing.
217
+ - **Docs** (`/mcp_logs/docs`) — the host's tool contract, read live from
218
+ `config.server`, grouped by `config.tool_sections`, with each tool's call
219
+ count linking back to the calls index.
220
+
221
+ ## Abandoned calls
222
+
223
+ A row is inserted `running` when a request starts and updated when it finishes.
224
+ If that update never lands — the container restarts mid-call for a deploy, or
225
+ the log write itself fails — nothing retries it. Left alone, the row would claim
226
+ to be running for the whole retention window, so an operator asking "what is the
227
+ agent doing right now" gets a permanent false positive.
228
+
229
+ `McpLogs::Call.sweep_abandoned!` settles those rows: anything still `running`
230
+ past `config.abandoned_after_hours` becomes `status: "error"` with
231
+ `error_type: "abandoned"` and an `error_message` saying no completion was ever
232
+ recorded. `completed_at` and `duration_ms` stay `nil` — the sweep knows the call
233
+ did not finish, not when it stopped.
234
+
235
+ It runs as part of `McpLogs::PurgeJob` and `rake mcp_logs:purge` below, so
236
+ scheduling either one covers both. Set `abandoned_after_hours` above your
237
+ slowest tool call: until the sweep runs, a long call and a dead one are
238
+ genuinely indistinguishable — the recorder cannot tell them apart, and neither
239
+ can the page.
240
+
241
+ ## Retention
242
+
243
+ Logged calls accumulate; nothing prunes them automatically. Two ways to run
244
+ `McpLogs::Call.purge!(McpLogs.retention_days)` and
245
+ `McpLogs::Call.sweep_abandoned!` on a schedule:
246
+
247
+ ```ruby
248
+ McpLogs::PurgeJob.perform_later
249
+ ```
250
+
251
+ ```sh
252
+ rake mcp_logs:purge
253
+ ```
254
+
255
+ With `solid_queue`'s `config/recurring.yml`:
256
+
257
+ ```yaml
258
+ mcp_logs_purge:
259
+ class: McpLogs::PurgeJob
260
+ schedule: every day at 3am
261
+ ```
262
+
263
+ ## Requirements
264
+
265
+ - Ruby >= 3.3
266
+ - Rails ~> 8.0
267
+ - PostgreSQL (the `arguments`/`response` columns are `jsonb`)
268
+ - An asset pipeline — Propshaft or `sprockets-rails`. The pages are styled by
269
+ the engine's own `mcp_logs.css`, and the engine ships
270
+ `app/assets/config/mcp_logs_manifest.js` declaring it, which is what Sprockets
271
+ needs to precompile an engine asset. Nothing to configure either way; without
272
+ a pipeline at all, the pages render unstyled.
273
+ - The `mcp` gem, supplied by the host — `mcp_logs` only depends on `rails`
274
+ and `pagy`
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+
8
+ require "rspec/core/rake_task"
9
+ RSpec::Core::RakeTask.new(:spec)
10
+
11
+ task default: :spec
@@ -0,0 +1 @@
1
+ //= link mcp_logs.css
@@ -0,0 +1,138 @@
1
+ /* mcp_logs — all styling for the engine's pages.
2
+ Plain CSS on purpose: a host's Tailwind build scans its own root and never
3
+ these files, so anything utility-class based would render unstyled. */
4
+
5
+ :root {
6
+ color-scheme: light dark;
7
+
8
+ --mcpl-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
9
+ --mcpl-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
10
+
11
+ --mcpl-bg: #f9fafb;
12
+ --mcpl-panel: #ffffff;
13
+ --mcpl-border: #e5e7eb;
14
+ --mcpl-text: #111827;
15
+ --mcpl-muted: #6b7280;
16
+ --mcpl-link: #2563eb;
17
+ --mcpl-nav-bg: #111827;
18
+ --mcpl-nav-text: #e5e7eb;
19
+ --mcpl-ok: #047857;
20
+ --mcpl-error: #b91c1c;
21
+ --mcpl-warn: #b45309;
22
+ --mcpl-running: #1d4ed8;
23
+ }
24
+
25
+ @media (prefers-color-scheme: dark) {
26
+ :root {
27
+ --mcpl-bg: #030712;
28
+ --mcpl-panel: #111827;
29
+ --mcpl-border: #1f2937;
30
+ --mcpl-text: #f3f4f6;
31
+ --mcpl-muted: #9ca3af;
32
+ --mcpl-link: #93c5fd;
33
+ --mcpl-nav-bg: #000000;
34
+ --mcpl-ok: #34d399;
35
+ --mcpl-error: #f87171;
36
+ --mcpl-warn: #fbbf24;
37
+ --mcpl-running: #93c5fd;
38
+ }
39
+ }
40
+
41
+ body.mcpl {
42
+ background: var(--mcpl-bg);
43
+ color: var(--mcpl-text);
44
+ font-family: var(--mcpl-font-sans);
45
+ font-size: 14px;
46
+ margin: 0;
47
+ }
48
+
49
+ .mcpl a { color: var(--mcpl-link); }
50
+
51
+ .mcpl-nav {
52
+ background: var(--mcpl-nav-bg);
53
+ color: var(--mcpl-nav-text);
54
+ padding: 0.75rem 1.5rem;
55
+ }
56
+ .mcpl-nav-inner { align-items: center; display: flex; gap: 1.5rem; margin: 0 auto; max-width: 80rem; }
57
+ .mcpl-nav strong { color: #fff; }
58
+ .mcpl-nav a { color: var(--mcpl-nav-text); text-decoration: none; }
59
+ .mcpl-nav a:hover, .mcpl-nav a[aria-current="page"] { color: #fff; text-decoration: underline; }
60
+
61
+ .mcpl-main { margin: 0 auto; max-width: 80rem; padding: 1.5rem; }
62
+
63
+ .mcpl-panel {
64
+ background: var(--mcpl-panel);
65
+ border: 1px solid var(--mcpl-border);
66
+ border-radius: 0.5rem;
67
+ margin-bottom: 1.5rem;
68
+ }
69
+ .mcpl-panel > h2 {
70
+ border-bottom: 1px solid var(--mcpl-border);
71
+ font-size: 0.75rem;
72
+ letter-spacing: 0.05em;
73
+ margin: 0;
74
+ padding: 0.75rem 1rem;
75
+ text-transform: uppercase;
76
+ }
77
+ .mcpl-panel-body { padding: 1rem; }
78
+
79
+ .mcpl-table { border-collapse: collapse; width: 100%; }
80
+ .mcpl-table th {
81
+ color: var(--mcpl-muted);
82
+ font-size: 0.75rem;
83
+ letter-spacing: 0.03em;
84
+ text-align: left;
85
+ text-transform: uppercase;
86
+ }
87
+ .mcpl-table th, .mcpl-table td {
88
+ border-bottom: 1px solid var(--mcpl-border);
89
+ padding: 0.5rem 1rem;
90
+ vertical-align: top;
91
+ }
92
+ .mcpl-table td.mcpl-mono, .mcpl-mono { font-family: var(--mcpl-font-mono); }
93
+
94
+ .mcpl-muted { color: var(--mcpl-muted); }
95
+
96
+ .mcpl-badge {
97
+ border-radius: 999px;
98
+ font-size: 0.75rem;
99
+ padding: 0.1rem 0.5rem;
100
+ border: 1px solid currentColor;
101
+ }
102
+ .mcpl-badge--ok { color: var(--mcpl-ok); }
103
+ .mcpl-badge--error { color: var(--mcpl-error); }
104
+ .mcpl-badge--warn { color: var(--mcpl-warn); }
105
+ .mcpl-badge--running { color: var(--mcpl-running); }
106
+
107
+ .mcpl-filters { align-items: flex-end; display: flex; flex-wrap: wrap; gap: 0.75rem; }
108
+ .mcpl-filters label { color: var(--mcpl-muted); display: block; font-size: 0.75rem; }
109
+ .mcpl-filters select, .mcpl-filters input {
110
+ background: var(--mcpl-panel);
111
+ border: 1px solid var(--mcpl-border);
112
+ border-radius: 0.25rem;
113
+ color: var(--mcpl-text);
114
+ padding: 0.25rem 0.5rem;
115
+ }
116
+
117
+ .mcpl-pagination { align-items: center; display: flex; gap: 1rem; justify-content: space-between; padding: 0.75rem 1rem; }
118
+
119
+ .mcpl-json {
120
+ background: var(--mcpl-bg);
121
+ border: 1px solid var(--mcpl-border);
122
+ border-radius: 0.375rem;
123
+ font-family: var(--mcpl-font-mono);
124
+ font-size: 0.8125rem;
125
+ overflow-x: auto;
126
+ padding: 0.75rem;
127
+ white-space: pre-wrap;
128
+ word-break: break-word;
129
+ }
130
+
131
+ .mcpl-meta { display: grid; gap: 0.5rem 1.5rem; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
132
+ .mcpl-meta dt { color: var(--mcpl-muted); font-size: 0.75rem; }
133
+ .mcpl-meta dd { font-family: var(--mcpl-font-mono); margin: 0 0 0.5rem; }
134
+
135
+ .mcpl-tool { border-top: 1px solid var(--mcpl-border); padding-top: 1rem; }
136
+ .mcpl-tool:first-child { border-top: 0; padding-top: 0; }
137
+ .mcpl-tool h3 { font-family: var(--mcpl-font-mono); font-size: 1rem; margin: 0 0 0.25rem; }
138
+ .mcpl-tool p { margin: 0 0 0.5rem; white-space: pre-line; }
@@ -0,0 +1,12 @@
1
+ module McpLogs
2
+ # Descends from whatever the host configured, so authentication, Current
3
+ # attributes and any other host-wide before_actions apply with no auth code in
4
+ # this gem. The constant is resolved when this file is first autoloaded, which
5
+ # is after the host's initializers have run.
6
+ #
7
+ # The layout is re-declared because the host's base controller almost certainly
8
+ # sets its own, and the engine's pages are styled by the engine's own CSS.
9
+ class ApplicationController < McpLogs.base_controller
10
+ layout "mcp_logs/application"
11
+ end
12
+ end
@@ -0,0 +1,34 @@
1
+ module McpLogs
2
+ class CallsController < ApplicationController
3
+ include Pagy::Method
4
+
5
+ def index
6
+ scope = Call.recent
7
+ .for_method(params[:method])
8
+ .for_tool(params[:tool])
9
+ .with_status(params[:status])
10
+ .for_session(params[:session])
11
+
12
+ @pagy, @calls = pagy(:offset, scope, limit: McpLogs.page_size)
13
+
14
+ # The filter dropdowns list what has actually been logged, so they can never
15
+ # offer a method or tool that would return an empty page.
16
+ @methods = filter_options(:rpc_method, params[:method])
17
+ @tools = filter_options(:tool_name, params[:tool])
18
+ end
19
+
20
+ def show
21
+ @call = Call.find(params[:id])
22
+ end
23
+
24
+ private
25
+
26
+ # Bounded by Call::FILTER_OPTIONS_LIMIT, with the active filter added back if
27
+ # the bound cut it — otherwise filtering by a tool past the cut would drop
28
+ # that tool out of the select, silently resetting it to "Any" on submit.
29
+ def filter_options(column, selected)
30
+ values = Call.filter_values(column)
31
+ selected.present? ? (values | [selected]).sort : values
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,9 @@
1
+ module McpLogs
2
+ class DocsController < ApplicationController
3
+ def show
4
+ @server = McpLogs.server
5
+ @sections = Docs.sections(@server)
6
+ @counts = Call.tool_counts
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ module McpLogs
2
+ module CallsHelper
3
+ def mcpl_status_badge(call)
4
+ label = call.tool_error && call.status == "ok" ? "tool error" : call.status
5
+ modifier = call.tool_error && call.status == "ok" ? "warn" : call.status
6
+
7
+ tag.span(label, class: "mcpl-badge mcpl-badge--#{modifier}")
8
+ end
9
+
10
+ def mcpl_duration(call)
11
+ return "—" if call.duration_ms.nil?
12
+ return "#{call.duration_ms.round} ms" if call.duration_ms < 1000
13
+
14
+ "#{(call.duration_ms / 1000).round(2)} s"
15
+ end
16
+
17
+ def mcpl_value(value)
18
+ value.presence || tag.span("—", class: "mcpl-muted")
19
+ end
20
+
21
+ def mcpl_json(value)
22
+ return tag.p("None.", class: "mcpl-muted") if value.blank?
23
+
24
+ tag.pre(JSON.pretty_generate(value), class: "mcpl-json")
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ module McpLogs
2
+ # Descends from ActiveJob::Base rather than the host's ApplicationJob: the gem
3
+ # has no business inheriting a host's retry policy or queue defaults.
4
+ class PurgeJob < ActiveJob::Base
5
+ # Retention alone never settles a row stranded in "running" — it stays that
6
+ # way until the day it is deleted. Both jobs of housekeeping run here so a
7
+ # host that schedules one thing gets both. Purge first: a stranded row past
8
+ # the retention window is deleted rather than swept.
9
+ def perform(retention_days = McpLogs.retention_days, abandoned_after_hours = McpLogs.abandoned_after_hours)
10
+ McpLogs::Call.purge!(retention_days)
11
+ McpLogs::Call.sweep_abandoned!(abandoned_after_hours)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ module McpLogs
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,70 @@
1
+ module McpLogs
2
+ # One JSON-RPC request served by an MCP server. Inserted as "running" the
3
+ # moment the request starts, updated when it finishes — so a long tool call is
4
+ # visible while it runs rather than only after it returns.
5
+ class Call < ApplicationRecord
6
+ STATUSES = %w[running ok error].freeze
7
+
8
+ # error_type for a row whose finish never landed. Its own value rather than
9
+ # an exception class name, because no exception was raised here — the
10
+ # process that would have raised one is gone.
11
+ ABANDONED = "abandoned".freeze
12
+
13
+ ABANDONED_MESSAGE = "No completion was ever recorded for this call. Whatever was " \
14
+ "serving it stopped first — a deploy, a crash, or a failed log " \
15
+ "write — so its result and duration are unknown.".freeze
16
+
17
+ # How many distinct values a filter dropdown will offer. Bounded because
18
+ # tool_name is raw client input (see Recorder::STRING_LIMIT): the number of
19
+ # distinct values is capped by requests served, not by tools that exist, so
20
+ # an unbounded SELECT DISTINCT would let one looping client put an <option>
21
+ # per request on every render of the page opened to investigate it.
22
+ FILTER_OPTIONS_LIMIT = 200
23
+
24
+ validates :rpc_method, presence: true
25
+ validates :started_at, presence: true
26
+ validates :status, inclusion: { in: STATUSES }
27
+
28
+ scope :recent, -> { order(started_at: :desc, id: :desc) }
29
+
30
+ # Filters take request params straight from the controller, so a blank value
31
+ # means "no filter" rather than "match the empty string".
32
+ scope :for_method, ->(value) { where(rpc_method: value) if value.present? }
33
+ scope :for_tool, ->(value) { where(tool_name: value) if value.present? }
34
+ scope :with_status, ->(value) { where(status: value) if value.present? }
35
+ scope :for_session, ->(value) { where(session_id: value) if value.present? }
36
+
37
+ def self.filter_values(column)
38
+ where.not(column => nil).distinct.order(column).limit(FILTER_OPTIONS_LIMIT).pluck(column)
39
+ end
40
+
41
+ def self.tool_counts
42
+ where.not(tool_name: nil).group(:tool_name).count
43
+ end
44
+
45
+ def self.purge!(retention_days = McpLogs.retention_days)
46
+ where(started_at: ...retention_days.to_i.days.ago).delete_all
47
+ end
48
+
49
+ # A row is inserted "running" and updated when the request finishes. If that
50
+ # update never lands — the container restarted mid-call, or the write itself
51
+ # failed — nothing retries it, so the row would claim to be running for the
52
+ # entire retention window: a permanent false positive on the page an
53
+ # operator reads to answer "what is the agent doing right now", and an
54
+ # inflated count in tool_counts.
55
+ #
56
+ # completed_at and duration_ms stay nil on purpose. The sweep knows the call
57
+ # did not finish; it does not know when it stopped, and stamping "now" would
58
+ # invent a fact. Returns how many rows it settled.
59
+ def self.sweep_abandoned!(hours = McpLogs.abandoned_after_hours)
60
+ where(status: "running")
61
+ .where(started_at: ...hours.to_i.hours.ago)
62
+ .update_all(status: "error", error_type: ABANDONED, error_message: ABANDONED_MESSAGE,
63
+ updated_at: Time.current)
64
+ end
65
+
66
+ def running? = status == "running"
67
+
68
+ def duration_seconds = duration_ms && duration_ms / 1000.0
69
+ end
70
+ end