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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +274 -0
- data/Rakefile +11 -0
- data/app/assets/config/mcp_logs_manifest.js +1 -0
- data/app/assets/stylesheets/mcp_logs.css +138 -0
- data/app/controllers/mcp_logs/application_controller.rb +12 -0
- data/app/controllers/mcp_logs/calls_controller.rb +34 -0
- data/app/controllers/mcp_logs/docs_controller.rb +9 -0
- data/app/helpers/mcp_logs/calls_helper.rb +27 -0
- data/app/jobs/mcp_logs/purge_job.rb +14 -0
- data/app/models/mcp_logs/application_record.rb +5 -0
- data/app/models/mcp_logs/call.rb +70 -0
- data/app/models/mcp_logs/docs.rb +81 -0
- data/app/models/mcp_logs/recorder.rb +204 -0
- data/app/views/layouts/mcp_logs/application.html.erb +25 -0
- data/app/views/mcp_logs/calls/_pagination.html.erb +18 -0
- data/app/views/mcp_logs/calls/index.html.erb +62 -0
- data/app/views/mcp_logs/calls/show.html.erb +104 -0
- data/app/views/mcp_logs/docs/show.html.erb +75 -0
- data/config/routes.rb +7 -0
- data/db/migrate/001_create_mcp_logs_calls.rb +49 -0
- data/lib/generators/mcp_logs/install_generator.rb +17 -0
- data/lib/generators/mcp_logs/templates/initializer.rb +36 -0
- data/lib/mcp_logs/configuration.rb +31 -0
- data/lib/mcp_logs/context.rb +42 -0
- data/lib/mcp_logs/engine.rb +9 -0
- data/lib/mcp_logs/payload.rb +43 -0
- data/lib/mcp_logs/version.rb +3 -0
- data/lib/mcp_logs.rb +92 -0
- data/lib/tasks/mcp_logs.rake +10 -0
- metadata +114 -0
|
@@ -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,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
|