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.
@@ -0,0 +1,81 @@
1
+ module McpLogs
2
+ # Renders a server's tool contract as reference documentation for a person, as
3
+ # opposed to the server itself, which serves it to an agent.
4
+ #
5
+ # Reads MCP::Tool.to_h rather than the tool classes' own DSL, because to_h is
6
+ # the exact payload tools/list returns — so the page cannot drift from what a
7
+ # connected client actually receives.
8
+ module Docs
9
+ Param = Data.define(:name, :type, :required, :description)
10
+ Tool = Data.define(:name, :title, :description, :params)
11
+ Section = Data.define(:title, :tools)
12
+
13
+ UNGROUPED = "Ungrouped".freeze
14
+
15
+ module_function
16
+
17
+ # `sections` is { "Title" => ["tool_name", ...] }, in the order the host wants
18
+ # them rendered. A tool missing from every section still renders, under
19
+ # UNGROUPED, so an omission is visible rather than silent.
20
+ def sections(server, sections = McpLogs.tool_sections)
21
+ return [] if server.nil?
22
+
23
+ by_name = tools_by_name(server)
24
+
25
+ grouped = sections.map do |title, names|
26
+ Section.new(title: title, tools: names.filter_map { |name| by_name[name.to_s] }.map { |klass| tool(klass) })
27
+ end
28
+
29
+ leftover = by_name.except(*sections.values.flatten.map(&:to_s))
30
+ grouped << Section.new(title: UNGROUPED, tools: leftover.values.map { |klass| tool(klass) }) if leftover.any?
31
+
32
+ grouped.reject { |section| section.tools.empty? }
33
+ end
34
+
35
+ # MCP::Server#tools is already { name => tool class }; going through it rather
36
+ # than the class list means the page shows exactly what the server serves.
37
+ def tools_by_name(server)
38
+ server.tools.transform_keys(&:to_s)
39
+ end
40
+
41
+ def tool(klass)
42
+ contract = klass.to_h
43
+
44
+ Tool.new(
45
+ name: contract[:name],
46
+ title: contract[:title],
47
+ description: contract[:description].to_s.strip,
48
+ params: params(contract[:inputSchema] || {})
49
+ )
50
+ end
51
+
52
+ # Property keys arrive as Symbols but the `required` array holds Strings, so
53
+ # membership has to be tested on strings. Comparing the Symbol key directly
54
+ # against `required` returns false for every parameter, silently rendering the
55
+ # whole contract as optional.
56
+ def params(schema)
57
+ required = Array(schema[:required] || schema["required"]).map(&:to_s)
58
+ properties = schema[:properties] || schema["properties"] || {}
59
+
60
+ properties.map do |name, spec|
61
+ spec = spec.symbolize_keys if spec.respond_to?(:symbolize_keys)
62
+
63
+ Param.new(
64
+ name: name.to_s,
65
+ type: type_label(spec),
66
+ required: required.include?(name.to_s),
67
+ description: spec[:description].to_s
68
+ )
69
+ end
70
+ end
71
+
72
+ # Folds enum and array item type into the type label rather than the view, so
73
+ # a schema's accepted values are visible on the page instead of only in the
74
+ # tool's source file.
75
+ def type_label(spec)
76
+ base = spec[:type].to_s
77
+ base = "#{base} of #{spec.dig(:items, :type)}" if base == "array" && spec.dig(:items, :type)
78
+ spec[:enum] ? "#{base} (#{spec[:enum].join(" | ")})" : base
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,204 @@
1
+ module McpLogs
2
+ # The body of the lambda a host hands to MCP::Configuration#around_request.
3
+ #
4
+ # The gem calls it as `around_request.call(data, &block)`, where `data` is a
5
+ # hash the server keeps writing into for the duration of the block: tool_name,
6
+ # tool_arguments, client and error all land *during* the call, not before it.
7
+ # So the row is inserted with what is known up front (the method) and completed
8
+ # with everything else once the block returns.
9
+ #
10
+ # Load-bearing requirement on the host: build a fresh MCP::Server per request.
11
+ # In mcp 1.0.0 the `data` hash above is not per-request — it is
12
+ # `@instrumentation_data`, a plain instance variable on the MCP::Server
13
+ # object, reassigned to `{}` at the top of every `instrument_call`
14
+ # (lib/mcp/instrumentation.rb). If one server instance serves two concurrent
15
+ # requests, request B's reassignment can land while A is still mid-handler,
16
+ # so A's later `tool_name`/`tool_arguments`/`error` writes end up in B's hash
17
+ # instead of A's. This recorder only holds the reference it was handed and
18
+ # trusts it describes the request in progress; it has no way to detect or
19
+ # correct for a shared server, so the host must not memoize or share one.
20
+ class Recorder
21
+ # error_message goes into a plain text column, not jsonb, and has no
22
+ # upstream size bound the way arguments/response do via Payload: exception
23
+ # messages routinely echo attribute values or an entire failing SQL
24
+ # statement. This is a size bound only, not redaction — filtering
25
+ # free-text exception messages is out of scope here.
26
+ ERROR_MESSAGE_BYTES = 2_000
27
+
28
+ # Cap for every string column a client can influence: tool_name, session_id,
29
+ # client_name, client_version, protocol_version, user_agent.
30
+ #
31
+ # These are not validated input. mcp 1.0.0 reads tool_name straight from
32
+ # params.name and hands it to this hook *before* looking the tool up, so an
33
+ # unknown tool records whatever the client sent; the rest are raw request
34
+ # headers. Uncapped, one looping client writes megabyte rows for the whole
35
+ # retention window and puts one <option> per distinct value on the calls
36
+ # page — taking down the page an operator would open to find out why.
37
+ #
38
+ # 255 is the migration's `limit:`, and PostgreSQL's varchar(n) counts
39
+ # characters, so this counts characters too and a value cut here always
40
+ # fits. It is far above every legitimate value (a tool name is an
41
+ # identifier, a session id a UUID, a protocol version a date), which is why
42
+ # the cut is silent: unlike a capped payload there is nothing an operator
43
+ # would want back, so it gets no *_truncated flag.
44
+ STRING_LIMIT = 255
45
+
46
+ def self.around_request(server_name: nil)
47
+ recorder = new(server_name: server_name)
48
+ ->(data, &handler) { recorder.call(data, &handler) }
49
+ end
50
+
51
+ def initialize(server_name: nil)
52
+ @server_name = server_name
53
+ end
54
+
55
+ def call(data, &handler)
56
+ return handler.call unless McpLogs.enabled?
57
+
58
+ record = start(data)
59
+ started = clock
60
+
61
+ begin
62
+ response = handler.call
63
+ rescue Exception => exception # rubocop:disable Lint/RescueException
64
+ # Re-raised immediately and unchanged; the rescue exists only so a
65
+ # failing request still gets a completed row rather than a stuck one.
66
+ finish(record, data, started: started, exception: exception)
67
+ raise
68
+ end
69
+
70
+ finish(record, data, started: started, response: response)
71
+ response
72
+ end
73
+
74
+ private
75
+
76
+ # data[:duration] is set in the gem's `ensure`, which runs after this lambda
77
+ # returns, so the recorder times the handler itself.
78
+ def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+
80
+ def start(data)
81
+ guarded do
82
+ Call.create!(
83
+ {
84
+ server_name: @server_name,
85
+ rpc_method: data[:method].to_s,
86
+ status: "running",
87
+ started_at: Time.current
88
+ }.merge(capped_context)
89
+ )
90
+ end
91
+ end
92
+
93
+ def finish(record, data, started:, response: nil, exception: nil)
94
+ return if record.nil?
95
+
96
+ # Measured before Payload.prepare and the write below do their own work,
97
+ # so duration_ms times the handler, not the recorder's own bookkeeping.
98
+ elapsed_ms = (clock - started) * 1000
99
+
100
+ guarded do
101
+ arguments, arguments_truncated = Payload.prepare(data[:tool_arguments])
102
+ body, response_truncated = Payload.prepare(result_payload(response))
103
+ error_message, error_message_truncated = capped_error_message(exception)
104
+
105
+ # update_columns, not update!: this writes every column named below
106
+ # unconditionally, where update! sends only the ones ActiveRecord considers
107
+ # dirty relative to the object `start` built. That difference matters for
108
+ # exactly one case, and it is not hypothetical. If the sweep settled this
109
+ # row as abandoned while the handler was still running, the row now carries
110
+ # an error_type and error_message this in-memory object has never seen —
111
+ # and since both are nil in memory AND nil in the assignment below, dirty
112
+ # tracking omits them, leaving a completed call permanently labelled
113
+ # abandoned. Validations go with them: these values are constructed rather
114
+ # than user input, and a validation failure would only be swallowed by
115
+ # `guarded` anyway.
116
+ record.update_columns(
117
+ updated_at: Time.current,
118
+ tool_name: capped(data[:tool_name]),
119
+ arguments: arguments,
120
+ arguments_truncated: arguments_truncated,
121
+ response: body,
122
+ response_truncated: response_truncated,
123
+ tool_error: tool_error?(response),
124
+ cancelled: !!data[:cancelled],
125
+ status: (exception || data[:error]) ? "error" : "ok",
126
+ error_type: data[:error]&.to_s || exception&.class&.name,
127
+ error_message: error_message,
128
+ error_message_truncated: error_message_truncated,
129
+ client_name: capped(client(data)[:name]),
130
+ client_version: capped(client(data)[:version]),
131
+ duration_ms: elapsed_ms,
132
+ completed_at: Time.current
133
+ )
134
+ end
135
+ end
136
+
137
+ # A cancelled request returns the gem's NO_RESPONSE sentinel, and non-tool
138
+ # methods can return anything; only a Hash is worth storing as a result.
139
+ def result_payload(response)
140
+ response.is_a?(Hash) ? response : nil
141
+ end
142
+
143
+ # An MCP::Tool::Response built with error: true serializes to isError: true.
144
+ # That is a *successful* JSON-RPC response carrying a tool-level failure —
145
+ # a rejected pattern, an already-resolved domain — so it gets its own flag
146
+ # rather than being folded into status.
147
+ def tool_error?(response)
148
+ return false unless response.is_a?(Hash)
149
+
150
+ !!(response[:isError] || response["isError"])
151
+ end
152
+
153
+ def client(data)
154
+ value = data[:client]
155
+ value.is_a?(Hash) ? value.symbolize_keys : {}
156
+ end
157
+
158
+ # actor is the host's own label rather than anything the client sent, but it
159
+ # shares a column width with the rest, so it is capped alongside them.
160
+ def capped_context
161
+ Context.current
162
+ .slice(:session_id, :protocol_version, :actor, :ip, :user_agent)
163
+ .transform_values { |value| capped(value) }
164
+ end
165
+
166
+ def capped(value)
167
+ value && value.to_s.truncate(STRING_LIMIT, omission: "")
168
+ end
169
+
170
+ def capped_error_message(exception)
171
+ return [nil, false] unless exception
172
+
173
+ message = "#{exception.class}: #{exception.message}"
174
+ return [message, false] if message.bytesize <= ERROR_MESSAGE_BYTES
175
+
176
+ # byteslice can cut mid-character; scrub keeps the column valid UTF-8.
177
+ [message.byteslice(0, ERROR_MESSAGE_BYTES).scrub(""), true]
178
+ end
179
+
180
+ # The log is never the reason a tool call fails. A failed statement inside
181
+ # an open PostgreSQL transaction leaves the connection aborted, so every
182
+ # subsequent query on it fails too — including the handler's own, if it is
183
+ # running inside a transaction of its own. requires_new: true opens a
184
+ # savepoint instead of reusing the surrounding one, so a failure here rolls
185
+ # back only the log write and leaves any enclosing transaction usable.
186
+ def guarded
187
+ Call.transaction(requires_new: true) { yield }
188
+ rescue StandardError => exception
189
+ # The report is itself guarded. Anything escaping this method reaches
190
+ # MCP::Instrumentation#instrument_call, which reports and re-raises, so a
191
+ # failing error reporter would turn an already-committed tool write into a
192
+ # -32603 for the client — which an agent will retry, duplicating the
193
+ # write. ErrorReporter#report already swallows subscriber failures when a
194
+ # logger is present, so this is near-unreachable; it is also the only line
195
+ # in the recorder that was outside the guarantee the rest of it makes.
196
+ begin
197
+ Rails.error.report(exception, handled: true, source: "mcp_logs")
198
+ rescue StandardError
199
+ nil
200
+ end
201
+ nil
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= content_for(:title) || "MCP Logs" %></title>
7
+ <%= stylesheet_link_tag "mcp_logs" %>
8
+ </head>
9
+
10
+ <body class="mcpl">
11
+ <nav class="mcpl-nav">
12
+ <div class="mcpl-nav-inner">
13
+ <strong>MCP Logs</strong>
14
+ <%= link_to "Calls", mcp_logs.calls_path,
15
+ "aria-current": ("page" if request.path.start_with?(mcp_logs.calls_path) || request.path == mcp_logs.root_path) %>
16
+ <%= link_to "Docs", mcp_logs.docs_path,
17
+ "aria-current": ("page" if request.path == mcp_logs.docs_path) %>
18
+ </div>
19
+ </nav>
20
+
21
+ <main class="mcpl-main">
22
+ <%= yield %>
23
+ </main>
24
+ </body>
25
+ </html>
@@ -0,0 +1,18 @@
1
+ <%# Built from the Pagy object's plain readers; pagy's own nav helpers ship their
2
+ own markup and stylesheet, neither of which fits this layout. %>
3
+ <nav class="mcpl-pagination" aria-label="Pagination">
4
+ <span class="mcpl-muted">
5
+ <% if pagy.count.zero? %>
6
+ Nothing to show
7
+ <% else %>
8
+ <%= number_with_delimiter(pagy.from) %>–<%= number_with_delimiter(pagy.to) %>
9
+ of <%= number_with_delimiter(pagy.count) %>
10
+ <% end %>
11
+ </span>
12
+
13
+ <span>
14
+ <%= link_to "‹ Prev", pagy.page_url(:previous) if pagy.previous %>
15
+ <span class="mcpl-muted">Page <%= pagy.page %> of <%= pagy.last %></span>
16
+ <%= link_to "Next ›", pagy.page_url(:next) if pagy.next %>
17
+ </span>
18
+ </nav>
@@ -0,0 +1,62 @@
1
+ <% content_for :title, "Calls — MCP Logs" %>
2
+
3
+ <div class="mcpl-panel">
4
+ <h2>Filter</h2>
5
+ <div class="mcpl-panel-body">
6
+ <%= form_with url: mcp_logs.calls_path, method: :get, class: "mcpl-filters" do |form| %>
7
+ <div>
8
+ <%= form.label :method, "Method" %>
9
+ <%= form.select :method, @methods, { include_blank: "Any" }, selected: params[:method] %>
10
+ </div>
11
+ <div>
12
+ <%= form.label :tool, "Tool" %>
13
+ <%= form.select :tool, @tools, { include_blank: "Any" }, selected: params[:tool] %>
14
+ </div>
15
+ <div>
16
+ <%= form.label :status, "Status" %>
17
+ <%= form.select :status, McpLogs::Call::STATUSES, { include_blank: "Any" }, selected: params[:status] %>
18
+ </div>
19
+ <div>
20
+ <%= form.label :session, "Session" %>
21
+ <%= form.text_field :session, value: params[:session] %>
22
+ </div>
23
+ <%= form.submit "Apply" %>
24
+ <%= link_to "Clear", mcp_logs.calls_path %>
25
+ <% end %>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="mcpl-panel">
30
+ <h2>Calls</h2>
31
+
32
+ <% if @calls.empty? %>
33
+ <div class="mcpl-panel-body mcpl-muted">No calls recorded yet.</div>
34
+ <% else %>
35
+ <table class="mcpl-table">
36
+ <thead>
37
+ <tr>
38
+ <th>Started</th>
39
+ <th>Method</th>
40
+ <th>Tool</th>
41
+ <th>Status</th>
42
+ <th>Duration</th>
43
+ <th>Client</th>
44
+ </tr>
45
+ </thead>
46
+ <tbody>
47
+ <% @calls.each do |call| %>
48
+ <tr>
49
+ <td><%= link_to l(call.started_at, format: :short), mcp_logs.call_path(call) %></td>
50
+ <td class="mcpl-mono"><%= call.rpc_method %></td>
51
+ <td class="mcpl-mono"><%= mcpl_value(call.tool_name) %></td>
52
+ <td><%= mcpl_status_badge(call) %></td>
53
+ <td><%= mcpl_duration(call) %></td>
54
+ <td><%= mcpl_value(call.actor || call.client_name) %></td>
55
+ </tr>
56
+ <% end %>
57
+ </tbody>
58
+ </table>
59
+ <% end %>
60
+
61
+ <%= render "pagination", pagy: @pagy %>
62
+ </div>
@@ -0,0 +1,104 @@
1
+ <% content_for :title, "Call ##{@call.id} — MCP Logs" %>
2
+
3
+ <p><%= link_to "← Calls", mcp_logs.calls_path %></p>
4
+
5
+ <div class="mcpl-panel">
6
+ <h2><%= @call.rpc_method %><%= " · #{@call.tool_name}" if @call.tool_name %></h2>
7
+ <div class="mcpl-panel-body">
8
+ <dl class="mcpl-meta">
9
+ <div>
10
+ <dt>Status</dt>
11
+ <dd><%= mcpl_status_badge(@call) %></dd>
12
+ </div>
13
+ <div>
14
+ <dt>Started</dt>
15
+ <dd><%= l(@call.started_at, format: :short) %></dd>
16
+ </div>
17
+ <div>
18
+ <dt>Duration</dt>
19
+ <dd><%= mcpl_duration(@call) %></dd>
20
+ </div>
21
+ <div>
22
+ <dt>Tool</dt>
23
+ <dd>
24
+ <% if @call.tool_name %>
25
+ <%= link_to @call.tool_name, mcp_logs.calls_path(tool: @call.tool_name) %>
26
+ <% else %>
27
+ <span class="mcpl-muted">—</span>
28
+ <% end %>
29
+ </dd>
30
+ </div>
31
+ <div>
32
+ <dt>Session</dt>
33
+ <dd>
34
+ <% if @call.session_id %>
35
+ <%= link_to @call.session_id, mcp_logs.calls_path(session: @call.session_id) %>
36
+ <% else %>
37
+ <span class="mcpl-muted">—</span>
38
+ <% end %>
39
+ </dd>
40
+ </div>
41
+ <div>
42
+ <dt>Actor</dt>
43
+ <dd><%= mcpl_value(@call.actor) %></dd>
44
+ </div>
45
+ <div>
46
+ <dt>Client</dt>
47
+ <dd><%= mcpl_value([@call.client_name, @call.client_version].compact.join(" ")) %></dd>
48
+ </div>
49
+ <div>
50
+ <dt>Protocol</dt>
51
+ <dd><%= mcpl_value(@call.protocol_version) %></dd>
52
+ </div>
53
+ <div>
54
+ <dt>Server</dt>
55
+ <dd><%= mcpl_value(@call.server_name) %></dd>
56
+ </div>
57
+ <div>
58
+ <dt>IP</dt>
59
+ <dd><%= mcpl_value(@call.ip) %></dd>
60
+ </div>
61
+ <div>
62
+ <dt>User agent</dt>
63
+ <dd><%= mcpl_value(@call.user_agent) %></dd>
64
+ </div>
65
+ <div>
66
+ <dt>Cancelled</dt>
67
+ <dd><%= @call.cancelled ? "yes" : "no" %></dd>
68
+ </div>
69
+ </dl>
70
+ </div>
71
+ </div>
72
+
73
+ <% if @call.status == "error" || @call.error_message.present? %>
74
+ <div class="mcpl-panel">
75
+ <h2>Error</h2>
76
+ <div class="mcpl-panel-body">
77
+ <% if @call.error_message_truncated %>
78
+ <p class="mcpl-muted">This message was capped at <%= number_with_delimiter(McpLogs::Recorder::ERROR_MESSAGE_BYTES) %> bytes — what follows is the capped text, not the whole message.</p>
79
+ <% end %>
80
+ <p class="mcpl-mono"><%= mcpl_value(@call.error_type) %></p>
81
+ <p><%= mcpl_value(@call.error_message) %></p>
82
+ </div>
83
+ </div>
84
+ <% end %>
85
+
86
+ <div class="mcpl-panel">
87
+ <h2>Arguments</h2>
88
+ <div class="mcpl-panel-body">
89
+ <% if @call.arguments_truncated %>
90
+ <p class="mcpl-muted">This payload was capped at <%= number_with_delimiter(McpLogs.max_payload_bytes) %> bytes — what follows is the capped record, not the whole exchange.</p>
91
+ <% end %>
92
+ <%= mcpl_json(@call.arguments) %>
93
+ </div>
94
+ </div>
95
+
96
+ <div class="mcpl-panel">
97
+ <h2>Response</h2>
98
+ <div class="mcpl-panel-body">
99
+ <% if @call.response_truncated %>
100
+ <p class="mcpl-muted">This payload was capped at <%= number_with_delimiter(McpLogs.max_payload_bytes) %> bytes — what follows is the capped record, not the whole exchange.</p>
101
+ <% end %>
102
+ <%= mcpl_json(@call.response) %>
103
+ </div>
104
+ </div>
@@ -0,0 +1,75 @@
1
+ <% content_for :title, "Docs — MCP Logs" %>
2
+
3
+ <% if @server.nil? %>
4
+ <div class="mcpl-panel">
5
+ <h2>Docs</h2>
6
+ <div class="mcpl-panel-body">
7
+ <p>No MCP server is configured. Set <code class="mcpl-mono">config.server</code> in your
8
+ <code class="mcpl-mono">mcp_logs</code> initializer to a lambda returning your
9
+ <code class="mcpl-mono">MCP::Server</code>, and this page will document its tools.</p>
10
+ </div>
11
+ </div>
12
+ <% else %>
13
+ <div class="mcpl-panel">
14
+ <h2><%= @server.name %></h2>
15
+ <div class="mcpl-panel-body">
16
+ <p class="mcpl-muted">
17
+ The contract this server serves, read live from it —
18
+ <%= pluralize(@server.tools.size, "tool") %>, version
19
+ <span class="mcpl-mono"><%= @server.version %></span>.
20
+ Clients fetch it once, at connect time, and cache it; after a deploy that changes it,
21
+ reconnect the client.
22
+ </p>
23
+ <% if @server.instructions.present? %>
24
+ <p style="white-space: pre-line"><%= @server.instructions %></p>
25
+ <% end %>
26
+ </div>
27
+ </div>
28
+
29
+ <% @sections.each do |section| %>
30
+ <div class="mcpl-panel">
31
+ <h2><%= section.title %></h2>
32
+ <div class="mcpl-panel-body">
33
+ <% section.tools.each do |tool| %>
34
+ <div class="mcpl-tool">
35
+ <h3><%= tool.name %></h3>
36
+ <p class="mcpl-muted">
37
+ <% count = @counts[tool.name].to_i %>
38
+ <% if count.zero? %>
39
+ Never called.
40
+ <% else %>
41
+ <%= link_to pluralize(count, "call"), mcp_logs.calls_path(tool: tool.name) %>
42
+ <% end %>
43
+ </p>
44
+ <p><%= tool.description %></p>
45
+
46
+ <% if tool.params.empty? %>
47
+ <p class="mcpl-muted">No parameters.</p>
48
+ <% else %>
49
+ <table class="mcpl-table">
50
+ <thead>
51
+ <tr>
52
+ <th>Param</th>
53
+ <th>Type</th>
54
+ <th>Required</th>
55
+ <th>Description</th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ <% tool.params.each do |param| %>
60
+ <tr>
61
+ <td class="mcpl-mono"><%= param.name %></td>
62
+ <td class="mcpl-muted"><%= param.type %></td>
63
+ <td class="mcpl-muted"><%= param.required ? "yes" : "—" %></td>
64
+ <td><%= param.description %></td>
65
+ </tr>
66
+ <% end %>
67
+ </tbody>
68
+ </table>
69
+ <% end %>
70
+ </div>
71
+ <% end %>
72
+ </div>
73
+ </div>
74
+ <% end %>
75
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ McpLogs::Engine.routes.draw do
2
+ root to: "calls#index"
3
+
4
+ resources :calls, only: [:index, :show]
5
+
6
+ get "docs", to: "docs#show", as: :docs
7
+ end
@@ -0,0 +1,49 @@
1
+ class CreateMcpLogsCalls < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :mcp_logs_calls do |t|
4
+ t.string :server_name
5
+ # Named rpc_method, not method: an ActiveRecord attribute called `method`
6
+ # shadows Object#method on every instance of this class.
7
+ t.string :rpc_method, null: false
8
+ # limit: 255 on every string a client can influence. tool_name in
9
+ # particular is not validated input — mcp 1.0.0 passes params.name to the
10
+ # around_request hook before looking the tool up — and the calls page
11
+ # renders one <option> per distinct value, so an unbounded column lets one
12
+ # client fill the table and take the page down. McpLogs::Recorder truncates
13
+ # to the same width at capture time; this is the backstop, and it is why
14
+ # the number is a character count rather than a byte count.
15
+ t.string :tool_name, limit: 255
16
+ t.jsonb :arguments
17
+ t.jsonb :response
18
+ t.string :status, null: false, default: "running"
19
+ t.boolean :tool_error, null: false, default: false
20
+ t.boolean :cancelled, null: false, default: false
21
+ t.string :error_type
22
+ t.text :error_message
23
+ t.float :duration_ms
24
+ t.string :client_name, limit: 255
25
+ t.string :client_version, limit: 255
26
+ t.string :protocol_version, limit: 255
27
+ t.string :session_id, limit: 255
28
+ t.string :actor, limit: 255
29
+ t.string :ip, limit: 255
30
+ t.string :user_agent, limit: 255
31
+ # One flag per capped payload, not a single OR'd column: each of these is
32
+ # set independently, so the detail page can say which payload was capped
33
+ # instead of pointing at whichever one happened to render nearby.
34
+ t.boolean :arguments_truncated, null: false, default: false
35
+ t.boolean :response_truncated, null: false, default: false
36
+ t.boolean :error_message_truncated, null: false, default: false
37
+ t.datetime :started_at, null: false
38
+ t.datetime :completed_at
39
+
40
+ t.timestamps
41
+ end
42
+
43
+ add_index :mcp_logs_calls, :started_at
44
+ add_index :mcp_logs_calls, :rpc_method
45
+ add_index :mcp_logs_calls, :tool_name
46
+ add_index :mcp_logs_calls, :status
47
+ add_index :mcp_logs_calls, :session_id
48
+ end
49
+ end
@@ -0,0 +1,17 @@
1
+ module McpLogs
2
+ class InstallGenerator < Rails::Generators::Base
3
+ source_root File.expand_path("templates", __dir__)
4
+
5
+ def copy_initializer
6
+ template "initializer.rb", "config/initializers/mcp_logs.rb"
7
+ end
8
+
9
+ def mount_engine
10
+ route 'mount McpLogs::Engine, at: "/mcp_logs"'
11
+ end
12
+
13
+ def copy_migrations
14
+ rake "mcp_logs:install:migrations"
15
+ end
16
+ end
17
+ end