debug-mcp 0.2.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +47 -0
- data/README.ja.md +15 -3
- data/README.md +15 -3
- data/lib/debug_mcp/notifications_subscriber.rb +162 -38
- data/lib/debug_mcp/rails_helper.rb +68 -0
- data/lib/debug_mcp/server.rb +15 -5
- data/lib/debug_mcp/tools/rails_info.rb +20 -0
- data/lib/debug_mcp/tools/rails_mail_deliveries.rb +222 -0
- data/lib/debug_mcp/tools/rails_recent_events.rb +187 -0
- data/lib/debug_mcp/version.rb +1 -1
- metadata +23 -80
- data/examples/01_simple_bug.rb +0 -43
- data/examples/02_data_pipeline.rb +0 -93
- data/examples/03_recursion.rb +0 -96
- data/examples/RAILS_SCENARIOS.md +0 -350
- data/examples/SCENARIOS.md +0 -142
- data/examples/rails_test_app/setup.sh +0 -428
- data/examples/rails_test_app/testapp/.dockerignore +0 -10
- data/examples/rails_test_app/testapp/.ruby-version +0 -1
- data/examples/rails_test_app/testapp/Dockerfile +0 -23
- data/examples/rails_test_app/testapp/Gemfile +0 -17
- data/examples/rails_test_app/testapp/README.md +0 -65
- data/examples/rails_test_app/testapp/Rakefile +0 -6
- data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
- data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +0 -1
- data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +0 -4
- data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +0 -38
- data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +0 -11
- data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +0 -100
- data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +0 -82
- data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +0 -25
- data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +0 -44
- data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +0 -2
- data/examples/rails_test_app/testapp/app/models/application_record.rb +0 -3
- data/examples/rails_test_app/testapp/app/models/comment.rb +0 -8
- data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/models/order.rb +0 -56
- data/examples/rails_test_app/testapp/app/models/order_item.rb +0 -16
- data/examples/rails_test_app/testapp/app/models/post.rb +0 -29
- data/examples/rails_test_app/testapp/app/models/user.rb +0 -34
- data/examples/rails_test_app/testapp/app/services/order_report_service.rb +0 -40
- data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +0 -28
- data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +0 -22
- data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +0 -26
- data/examples/rails_test_app/testapp/bin/ci +0 -6
- data/examples/rails_test_app/testapp/bin/dev +0 -2
- data/examples/rails_test_app/testapp/bin/rails +0 -4
- data/examples/rails_test_app/testapp/bin/rake +0 -4
- data/examples/rails_test_app/testapp/bin/setup +0 -35
- data/examples/rails_test_app/testapp/config/application.rb +0 -42
- data/examples/rails_test_app/testapp/config/boot.rb +0 -3
- data/examples/rails_test_app/testapp/config/ci.rb +0 -14
- data/examples/rails_test_app/testapp/config/database.yml +0 -32
- data/examples/rails_test_app/testapp/config/environment.rb +0 -5
- data/examples/rails_test_app/testapp/config/environments/development.rb +0 -54
- data/examples/rails_test_app/testapp/config/environments/production.rb +0 -67
- data/examples/rails_test_app/testapp/config/environments/test.rb +0 -42
- data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +0 -29
- data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +0 -8
- data/examples/rails_test_app/testapp/config/initializers/inflections.rb +0 -16
- data/examples/rails_test_app/testapp/config/locales/en.yml +0 -31
- data/examples/rails_test_app/testapp/config/puma.rb +0 -39
- data/examples/rails_test_app/testapp/config/routes.rb +0 -34
- data/examples/rails_test_app/testapp/config.ru +0 -6
- data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +0 -12
- data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +0 -13
- data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +0 -11
- data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +0 -14
- data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +0 -13
- data/examples/rails_test_app/testapp/db/schema.rb +0 -71
- data/examples/rails_test_app/testapp/db/seeds.rb +0 -85
- data/examples/rails_test_app/testapp/docker-compose.yml +0 -21
- data/examples/rails_test_app/testapp/docker-entrypoint.sh +0 -10
- data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
- data/examples/rails_test_app/testapp/log/.keep +0 -0
- data/examples/rails_test_app/testapp/public/400.html +0 -135
- data/examples/rails_test_app/testapp/public/404.html +0 -135
- data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +0 -135
- data/examples/rails_test_app/testapp/public/422.html +0 -135
- data/examples/rails_test_app/testapp/public/500.html +0 -135
- data/examples/rails_test_app/testapp/public/icon.png +0 -0
- data/examples/rails_test_app/testapp/public/icon.svg +0 -3
- data/examples/rails_test_app/testapp/public/robots.txt +0 -1
- data/examples/rails_test_app/testapp/script/.keep +0 -0
- data/examples/rails_test_app/testapp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/vendor/.keep +0 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require "base64"
|
|
5
|
+
require_relative "../rails_helper"
|
|
6
|
+
|
|
7
|
+
module DebugMcp
|
|
8
|
+
module Tools
|
|
9
|
+
# Read ActionMailer::Base.deliveries from the running process.
|
|
10
|
+
#
|
|
11
|
+
# Observability caveat (surfaced in every response): deliveries is only
|
|
12
|
+
# populated when delivery_method is :test. With :smtp / :letter_opener / etc.
|
|
13
|
+
# the array is usually empty, so an empty result is NOT proof that no mail
|
|
14
|
+
# was sent — it may simply not be observable this way.
|
|
15
|
+
#
|
|
16
|
+
# PII: recipients, subject and body can contain personal data or secrets, and
|
|
17
|
+
# Rails' filter_parameters does NOT apply to this path. Bodies are truncated
|
|
18
|
+
# to a preview by default; attachment CONTENT is never returned (only name
|
|
19
|
+
# and content type).
|
|
20
|
+
class RailsMailDeliveries < MCP::Tool
|
|
21
|
+
description "[Investigation] Show emails captured in ActionMailer::Base.deliveries (from, to, " \
|
|
22
|
+
"cc, bcc, subject, body preview, attachment names). Only populated when " \
|
|
23
|
+
"delivery_method is :test — with :smtp/:letter_opener the list is usually empty, so " \
|
|
24
|
+
"an empty result does NOT prove no mail was sent (the response says whether it is " \
|
|
25
|
+
"observable). Bodies are truncated by default and may contain PII; attachment content " \
|
|
26
|
+
"is never returned. Requires the process to be paused."
|
|
27
|
+
|
|
28
|
+
annotations(
|
|
29
|
+
title: "Rails Mail Deliveries",
|
|
30
|
+
read_only_hint: true,
|
|
31
|
+
destructive_hint: false,
|
|
32
|
+
open_world_hint: false,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DEFAULT_LIMIT = 20
|
|
36
|
+
MAX_LIMIT = 200
|
|
37
|
+
DEFAULT_PREVIEW_CHARS = 500
|
|
38
|
+
MAX_PREVIEW_CHARS = 5000
|
|
39
|
+
# Hard ceiling applied even when include_body is true, so a multi-megabyte
|
|
40
|
+
# body can never cross the line-oriented debug socket as one JSON line.
|
|
41
|
+
MAX_BODY_CHARS = 50_000
|
|
42
|
+
|
|
43
|
+
input_schema(
|
|
44
|
+
properties: {
|
|
45
|
+
limit: {
|
|
46
|
+
type: "integer",
|
|
47
|
+
description: "How many of the most recent deliveries to show (default: #{DEFAULT_LIMIT}).",
|
|
48
|
+
},
|
|
49
|
+
include_body: {
|
|
50
|
+
type: "boolean",
|
|
51
|
+
description: "Return more of the body (still capped at #{MAX_BODY_CHARS} chars) instead of " \
|
|
52
|
+
"a short preview. Off by default because bodies may contain PII/secrets.",
|
|
53
|
+
},
|
|
54
|
+
body_preview_chars: {
|
|
55
|
+
type: "integer",
|
|
56
|
+
description: "Preview length when include_body is false " \
|
|
57
|
+
"(default: #{DEFAULT_PREVIEW_CHARS}, max: #{MAX_PREVIEW_CHARS}).",
|
|
58
|
+
},
|
|
59
|
+
session_id: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
def call(limit: nil, include_body: false, body_preview_chars: nil, session_id: nil, server_context:)
|
|
68
|
+
client = server_context[:session_manager].client(session_id)
|
|
69
|
+
client.auto_repause!
|
|
70
|
+
RailsHelper.require_rails!(client)
|
|
71
|
+
|
|
72
|
+
# The probe iterates Mail objects and puts a JSON line; both need a
|
|
73
|
+
# normal (non-trap) paused context.
|
|
74
|
+
if RailsHelper.trap_context?(client)
|
|
75
|
+
return text_response("Rails mail deliveries: unavailable in trap context.\n\n" \
|
|
76
|
+
"#{RailsHelper::TRAP_CONTEXT_HINT}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
data = fetch_deliveries(
|
|
80
|
+
client,
|
|
81
|
+
limit: resolve_limit(limit),
|
|
82
|
+
include_body: include_body ? true : false,
|
|
83
|
+
preview_chars: resolve_preview(body_preview_chars),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if data.nil? || data.empty? || data[:error]
|
|
87
|
+
msg = data && data[:error] ? "Error: #{data[:error]}" : "Rails mail deliveries: unavailable."
|
|
88
|
+
msg += "\n\n#{RailsHelper::TRAP_CONTEXT_HINT}" if RailsHelper.trap_context?(client)
|
|
89
|
+
return text_response(msg)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
text_response(format_output(data))
|
|
93
|
+
rescue DebugMcp::Error => e
|
|
94
|
+
text_response("Error: #{e.message}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def resolve_limit(limit)
|
|
100
|
+
n = limit.to_i
|
|
101
|
+
n = DEFAULT_LIMIT unless n.positive?
|
|
102
|
+
[n, MAX_LIMIT].min
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_preview(chars)
|
|
106
|
+
n = chars.to_i
|
|
107
|
+
return DEFAULT_PREVIEW_CHARS unless n.positive?
|
|
108
|
+
|
|
109
|
+
[n, MAX_PREVIEW_CHARS].min
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fetch_deliveries(client, limit:, include_body:, preview_chars:)
|
|
113
|
+
script = build_script(limit: limit, include_body: include_body, preview_chars: preview_chars)
|
|
114
|
+
encoded = Base64.strict_encode64(script)
|
|
115
|
+
cmd = "require 'base64'; eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8'))"
|
|
116
|
+
result = client.send_command(cmd, timeout: 15)
|
|
117
|
+
# The script's value is a base64 JSON blob (see build_script) — the
|
|
118
|
+
# debug socket does not forward the debuggee's stdout, so we return the
|
|
119
|
+
# data as the evaluated expression's value, not via puts.
|
|
120
|
+
RailsHelper.decode_json_result(result, nil)
|
|
121
|
+
rescue DebugMcp::Error
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Runs inside the target process. Truncates the body and strips newlines
|
|
126
|
+
# there (not in the MCP layer) so the result stays small, and returns the
|
|
127
|
+
# JSON base64-encoded as the script's value (send_command only sees the
|
|
128
|
+
# evaluated value, not puts output).
|
|
129
|
+
def build_script(limit:, include_body:, preview_chars:)
|
|
130
|
+
<<~RUBY
|
|
131
|
+
__result = begin
|
|
132
|
+
if defined?(ActionMailer::Base)
|
|
133
|
+
dm = ActionMailer::Base.delivery_method
|
|
134
|
+
deliveries = ActionMailer::Base.deliveries
|
|
135
|
+
shown = deliveries.last(#{limit})
|
|
136
|
+
offset = deliveries.size - shown.size
|
|
137
|
+
items = shown.map.with_index do |m, i|
|
|
138
|
+
raw = begin
|
|
139
|
+
if m.multipart?
|
|
140
|
+
part = m.text_part || m.html_part
|
|
141
|
+
part ? part.body.decoded.to_s : ""
|
|
142
|
+
else
|
|
143
|
+
m.body.decoded.to_s
|
|
144
|
+
end
|
|
145
|
+
rescue StandardError
|
|
146
|
+
""
|
|
147
|
+
end
|
|
148
|
+
full_len = raw.length
|
|
149
|
+
cap = #{include_body} ? #{MAX_BODY_CHARS} : #{preview_chars}
|
|
150
|
+
shown = raw[0, cap].to_s.gsub(/\\s+/, " ").strip
|
|
151
|
+
atts = (m.attachments || []).map { |a|
|
|
152
|
+
{ filename: a.filename.to_s[0, 200], content_type: a.content_type.to_s[0, 100] }
|
|
153
|
+
} rescue []
|
|
154
|
+
{
|
|
155
|
+
index: offset + i,
|
|
156
|
+
from: Array(m.from).join(", ")[0, 500],
|
|
157
|
+
to: Array(m.to).join(", ")[0, 1000],
|
|
158
|
+
cc: Array(m.cc).join(", ")[0, 1000],
|
|
159
|
+
bcc: Array(m.bcc).join(", ")[0, 1000],
|
|
160
|
+
subject: m.subject.to_s.gsub(/\\s+/, " ").strip[0, 500],
|
|
161
|
+
multipart: (m.multipart? rescue false),
|
|
162
|
+
body_preview: shown,
|
|
163
|
+
body_truncated: (full_len > cap),
|
|
164
|
+
attachments: atts,
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
{ observable: (dm == :test), delivery_method: dm.to_s,
|
|
168
|
+
total: deliveries.size, deliveries: items }.to_json
|
|
169
|
+
else
|
|
170
|
+
{ observable: false, delivery_method: "(ActionMailer not loaded)",
|
|
171
|
+
total: 0, deliveries: [] }.to_json
|
|
172
|
+
end
|
|
173
|
+
rescue => e
|
|
174
|
+
{ error: e.class.to_s + ": " + e.message }.to_json
|
|
175
|
+
end
|
|
176
|
+
[__result].pack("m0")
|
|
177
|
+
RUBY
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def format_output(data)
|
|
181
|
+
lines = ["=== Rails Mail Deliveries ==="]
|
|
182
|
+
lines << "delivery_method: #{data[:delivery_method]}"
|
|
183
|
+
lines << "observable: #{data[:observable] ? true : false}"
|
|
184
|
+
unless data[:observable]
|
|
185
|
+
lines << "Note: deliveries is only populated when delivery_method is :test. " \
|
|
186
|
+
"An empty list does NOT prove no mail was sent."
|
|
187
|
+
end
|
|
188
|
+
lines << "total captured: #{data[:total]}"
|
|
189
|
+
|
|
190
|
+
deliveries = data[:deliveries] || []
|
|
191
|
+
if deliveries.empty?
|
|
192
|
+
lines << "\n(no deliveries captured)"
|
|
193
|
+
return lines.join("\n")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
deliveries.each do |m|
|
|
197
|
+
lines << ""
|
|
198
|
+
lines << "## [#{m[:index]}] #{m[:subject]}"
|
|
199
|
+
lines << "from: #{m[:from]}"
|
|
200
|
+
lines << "to: #{m[:to]}"
|
|
201
|
+
lines << "cc: #{m[:cc]}" unless m[:cc].to_s.empty?
|
|
202
|
+
lines << "bcc: #{m[:bcc]}" unless m[:bcc].to_s.empty?
|
|
203
|
+
lines << "multipart: #{m[:multipart]}"
|
|
204
|
+
if (atts = m[:attachments]) && !atts.empty?
|
|
205
|
+
names = atts.map { |a| "#{a[:filename]} (#{a[:content_type]})" }.join(", ")
|
|
206
|
+
lines << "attachments: #{names}"
|
|
207
|
+
end
|
|
208
|
+
preview = m[:body_preview].to_s
|
|
209
|
+
trunc = m[:body_truncated] ? " [truncated]" : ""
|
|
210
|
+
lines << "body: #{preview}#{trunc}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
lines.join("\n")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def text_response(text)
|
|
217
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../rails_helper"
|
|
5
|
+
require_relative "../notifications_subscriber"
|
|
6
|
+
require_relative "../event_formatter"
|
|
7
|
+
|
|
8
|
+
module DebugMcp
|
|
9
|
+
module Tools
|
|
10
|
+
# Read recent Rails internal events (SQL, render, cache, job enqueue,
|
|
11
|
+
# request lifecycle) from the in-process Notifications buffer, independent
|
|
12
|
+
# of trigger_request.
|
|
13
|
+
#
|
|
14
|
+
# IMPORTANT semantics, surfaced in every response so the model does not
|
|
15
|
+
# mistake "empty" for "nothing happened":
|
|
16
|
+
# - forward-only: only events fired AFTER the subscriber was installed are
|
|
17
|
+
# visible. The first call installs the subscriber, so it usually returns
|
|
18
|
+
# little or nothing — that is not evidence that no SQL/jobs ran.
|
|
19
|
+
# - paused-only: the target must be paused at a debugger prompt (we send
|
|
20
|
+
# commands over the debug socket).
|
|
21
|
+
# - NOT read-only: installing the subscriber subscribes to
|
|
22
|
+
# ActiveSupport::Notifications inside the target — an in-process
|
|
23
|
+
# instrumentation side effect (no application data is written).
|
|
24
|
+
class RailsRecentEvents < MCP::Tool
|
|
25
|
+
description "[Investigation] Show recent Rails internal events (SQL, renders, cache, job " \
|
|
26
|
+
"enqueues, request lifecycle) captured from the running process, without needing " \
|
|
27
|
+
"trigger_request. FORWARD-ONLY: the first call installs an ActiveSupport::Notifications " \
|
|
28
|
+
"subscriber and only events fired AFTER that are visible — an empty result is NOT proof " \
|
|
29
|
+
"that nothing happened. Requires the process to be paused (set a breakpoint / use " \
|
|
30
|
+
"trigger_request first on threaded servers). Installing the subscriber is an in-process " \
|
|
31
|
+
"instrumentation side effect, so this tool is not strictly read-only."
|
|
32
|
+
|
|
33
|
+
annotations(
|
|
34
|
+
title: "Rails Recent Events",
|
|
35
|
+
# Not read-only: installs an in-process Notifications subscriber.
|
|
36
|
+
read_only_hint: false,
|
|
37
|
+
destructive_hint: false,
|
|
38
|
+
open_world_hint: false,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
DEFAULT_LIMIT = 50
|
|
42
|
+
MAX_LIMIT = 500
|
|
43
|
+
|
|
44
|
+
input_schema(
|
|
45
|
+
properties: {
|
|
46
|
+
kinds: {
|
|
47
|
+
type: "array",
|
|
48
|
+
items: { type: "string", enum: %w[sql render cache job request] },
|
|
49
|
+
description: "Filter to these event kinds (default: all). " \
|
|
50
|
+
"One or more of: sql, render, cache, job, request.",
|
|
51
|
+
},
|
|
52
|
+
limit: {
|
|
53
|
+
type: "integer",
|
|
54
|
+
description: "How many of the most recent buffered events to scan (default: #{DEFAULT_LIMIT}).",
|
|
55
|
+
},
|
|
56
|
+
after_seq: {
|
|
57
|
+
type: "integer",
|
|
58
|
+
description: "Cursor: return only events with seq greater than this. Use the previous " \
|
|
59
|
+
"response's newest_seq to page forward. Clock-independent (preferred over time).",
|
|
60
|
+
},
|
|
61
|
+
include_debug_eval: {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
description: "Include events caused by debug-mcp's own evaluate_code/inspect_object calls " \
|
|
64
|
+
"(default: false).",
|
|
65
|
+
},
|
|
66
|
+
session_id: {
|
|
67
|
+
type: "string",
|
|
68
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
KIND_MATCHERS = {
|
|
74
|
+
"sql" => ->(name) { name == "sql.active_record" },
|
|
75
|
+
"render" => ->(name) { name.start_with?("render_") },
|
|
76
|
+
"cache" => ->(name) { name.start_with?("cache_") },
|
|
77
|
+
"job" => ->(name) { name == "enqueue.active_job" },
|
|
78
|
+
"request" => ->(name) { name.end_with?(".action_controller") },
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
def call(kinds: nil, limit: nil, after_seq: nil, include_debug_eval: false, session_id: nil,
|
|
83
|
+
server_context:)
|
|
84
|
+
client = server_context[:session_manager].client(session_id)
|
|
85
|
+
client.auto_repause!
|
|
86
|
+
RailsHelper.require_rails!(client)
|
|
87
|
+
|
|
88
|
+
# Reads and install both send commands over the debug socket and both
|
|
89
|
+
# take the buffer/Notifications mutex, which fails in trap context.
|
|
90
|
+
if RailsHelper.trap_context?(client)
|
|
91
|
+
return text_response("Rails recent events: unavailable in trap context.\n\n" \
|
|
92
|
+
"#{RailsHelper::TRAP_CONTEXT_HINT}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
installed = NotificationsSubscriber.install(client)
|
|
96
|
+
unless installed
|
|
97
|
+
return text_response("Rails recent events: could not install the Notifications subscriber " \
|
|
98
|
+
"in the target process.\n\n#{RailsHelper::TRAP_CONTEXT_HINT}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
limit = resolve_limit(limit)
|
|
102
|
+
events = fetch_events(client, after_seq: after_seq, limit: limit)
|
|
103
|
+
events = filter_by_kinds(events, kinds)
|
|
104
|
+
metadata = NotificationsSubscriber.metadata(client)
|
|
105
|
+
|
|
106
|
+
# `installed` is the result of the install we just performed — the only
|
|
107
|
+
# trustworthy signal here. metadata[:installed] can be missing if the
|
|
108
|
+
# metadata round-trip itself returned no parseable object.
|
|
109
|
+
text_response(build_output(events, metadata, kinds, include_debug_eval, limit, installed))
|
|
110
|
+
rescue DebugMcp::Error => e
|
|
111
|
+
text_response("Error: #{e.message}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def resolve_limit(limit)
|
|
117
|
+
n = limit.to_i
|
|
118
|
+
n = DEFAULT_LIMIT unless n.positive?
|
|
119
|
+
[n, MAX_LIMIT].min
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def fetch_events(client, after_seq:, limit:)
|
|
123
|
+
if after_seq
|
|
124
|
+
# Forward cursor paging: oldest-after-cursor first, capped by limit
|
|
125
|
+
# (target-side) so a busy buffer never dumps all 1000 events.
|
|
126
|
+
NotificationsSubscriber.fetch_after_seq(client, after_seq.to_i, limit)
|
|
127
|
+
else
|
|
128
|
+
NotificationsSubscriber.fetch_last(client, limit)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def filter_by_kinds(events, kinds)
|
|
133
|
+
return events if kinds.nil? || kinds.empty?
|
|
134
|
+
|
|
135
|
+
matchers = kinds.filter_map { |k| KIND_MATCHERS[k.to_s] }
|
|
136
|
+
# All requested kinds were invalid: return nothing rather than silently
|
|
137
|
+
# falling back to every event (which would expose more, not less).
|
|
138
|
+
return [] if matchers.empty?
|
|
139
|
+
|
|
140
|
+
events.select { |e| matchers.any? { |m| m.call(e[:name].to_s) } }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_output(events, metadata, kinds, include_debug_eval, limit, installed)
|
|
144
|
+
header = build_metadata_header(metadata, kinds, include_debug_eval, installed)
|
|
145
|
+
# Align EventFormatter's per-section display caps with the user's limit
|
|
146
|
+
# so the "... and N more (limit=...)" note matches what was requested
|
|
147
|
+
# instead of EventFormatter's internal defaults.
|
|
148
|
+
limits = EventFormatter::DEFAULT_LIMITS.merge(
|
|
149
|
+
sql: limit, render: limit, cache: limit, job: limit,
|
|
150
|
+
)
|
|
151
|
+
formatted = EventFormatter.format(events, limits: limits, include_debug_eval: include_debug_eval)
|
|
152
|
+
|
|
153
|
+
body = if formatted.nil? || formatted.empty?
|
|
154
|
+
"(no matching events captured since the subscriber was installed)"
|
|
155
|
+
else
|
|
156
|
+
formatted
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
"#{header}\n\n#{body}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_metadata_header(metadata, kinds, include_debug_eval, installed)
|
|
163
|
+
lines = ["=== Rails Recent Events ==="]
|
|
164
|
+
lines << "installed: #{installed ? true : false}"
|
|
165
|
+
lines << "forward_only: true (only events fired after install are visible)"
|
|
166
|
+
lines << "paused_only: true"
|
|
167
|
+
lines << "events_before_install_are_unavailable: true"
|
|
168
|
+
lines << "installed_at: #{metadata[:installed_at] || "(unknown)"}"
|
|
169
|
+
if metadata[:buffer_size]
|
|
170
|
+
lines << "buffer: #{metadata[:buffer_size]}/#{metadata[:buffer_max]} " \
|
|
171
|
+
"(dropped: #{metadata[:dropped_count] || 0})"
|
|
172
|
+
end
|
|
173
|
+
if metadata[:oldest_seq] || metadata[:newest_seq]
|
|
174
|
+
lines << "seq range: #{metadata[:oldest_seq] || "-"}..#{metadata[:newest_seq] || "-"}"
|
|
175
|
+
end
|
|
176
|
+
lines << "filtered_kinds: #{kinds && !kinds.empty? ? kinds.join(", ") : "all"}"
|
|
177
|
+
lines << "include_debug_eval: #{include_debug_eval ? true : false}"
|
|
178
|
+
lines.join("\n")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def text_response(text)
|
|
182
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/debug_mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: debug-mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rira100000000
|
|
@@ -65,6 +65,26 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '1.9'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: activesupport
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '7.0'
|
|
75
|
+
- - "<"
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '9.0'
|
|
78
|
+
type: :development
|
|
79
|
+
prerelease: false
|
|
80
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '7.0'
|
|
85
|
+
- - "<"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '9.0'
|
|
68
88
|
- !ruby/object:Gem::Dependency
|
|
69
89
|
name: rake
|
|
70
90
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -109,85 +129,6 @@ files:
|
|
|
109
129
|
- LICENSE
|
|
110
130
|
- README.ja.md
|
|
111
131
|
- README.md
|
|
112
|
-
- examples/01_simple_bug.rb
|
|
113
|
-
- examples/02_data_pipeline.rb
|
|
114
|
-
- examples/03_recursion.rb
|
|
115
|
-
- examples/RAILS_SCENARIOS.md
|
|
116
|
-
- examples/SCENARIOS.md
|
|
117
|
-
- examples/rails_test_app/setup.sh
|
|
118
|
-
- examples/rails_test_app/testapp/.dockerignore
|
|
119
|
-
- examples/rails_test_app/testapp/.ruby-version
|
|
120
|
-
- examples/rails_test_app/testapp/Dockerfile
|
|
121
|
-
- examples/rails_test_app/testapp/Gemfile
|
|
122
|
-
- examples/rails_test_app/testapp/README.md
|
|
123
|
-
- examples/rails_test_app/testapp/Rakefile
|
|
124
|
-
- examples/rails_test_app/testapp/app/assets/images/.keep
|
|
125
|
-
- examples/rails_test_app/testapp/app/assets/stylesheets/application.css
|
|
126
|
-
- examples/rails_test_app/testapp/app/controllers/application_controller.rb
|
|
127
|
-
- examples/rails_test_app/testapp/app/controllers/concerns/.keep
|
|
128
|
-
- examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb
|
|
129
|
-
- examples/rails_test_app/testapp/app/controllers/health_controller.rb
|
|
130
|
-
- examples/rails_test_app/testapp/app/controllers/orders_controller.rb
|
|
131
|
-
- examples/rails_test_app/testapp/app/controllers/posts_controller.rb
|
|
132
|
-
- examples/rails_test_app/testapp/app/controllers/sessions_controller.rb
|
|
133
|
-
- examples/rails_test_app/testapp/app/controllers/users_controller.rb
|
|
134
|
-
- examples/rails_test_app/testapp/app/helpers/application_helper.rb
|
|
135
|
-
- examples/rails_test_app/testapp/app/models/application_record.rb
|
|
136
|
-
- examples/rails_test_app/testapp/app/models/comment.rb
|
|
137
|
-
- examples/rails_test_app/testapp/app/models/concerns/.keep
|
|
138
|
-
- examples/rails_test_app/testapp/app/models/order.rb
|
|
139
|
-
- examples/rails_test_app/testapp/app/models/order_item.rb
|
|
140
|
-
- examples/rails_test_app/testapp/app/models/post.rb
|
|
141
|
-
- examples/rails_test_app/testapp/app/models/user.rb
|
|
142
|
-
- examples/rails_test_app/testapp/app/services/order_report_service.rb
|
|
143
|
-
- examples/rails_test_app/testapp/app/views/layouts/application.html.erb
|
|
144
|
-
- examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb
|
|
145
|
-
- examples/rails_test_app/testapp/app/views/pwa/service-worker.js
|
|
146
|
-
- examples/rails_test_app/testapp/bin/ci
|
|
147
|
-
- examples/rails_test_app/testapp/bin/dev
|
|
148
|
-
- examples/rails_test_app/testapp/bin/rails
|
|
149
|
-
- examples/rails_test_app/testapp/bin/rake
|
|
150
|
-
- examples/rails_test_app/testapp/bin/setup
|
|
151
|
-
- examples/rails_test_app/testapp/config.ru
|
|
152
|
-
- examples/rails_test_app/testapp/config/application.rb
|
|
153
|
-
- examples/rails_test_app/testapp/config/boot.rb
|
|
154
|
-
- examples/rails_test_app/testapp/config/ci.rb
|
|
155
|
-
- examples/rails_test_app/testapp/config/database.yml
|
|
156
|
-
- examples/rails_test_app/testapp/config/environment.rb
|
|
157
|
-
- examples/rails_test_app/testapp/config/environments/development.rb
|
|
158
|
-
- examples/rails_test_app/testapp/config/environments/production.rb
|
|
159
|
-
- examples/rails_test_app/testapp/config/environments/test.rb
|
|
160
|
-
- examples/rails_test_app/testapp/config/initializers/content_security_policy.rb
|
|
161
|
-
- examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb
|
|
162
|
-
- examples/rails_test_app/testapp/config/initializers/inflections.rb
|
|
163
|
-
- examples/rails_test_app/testapp/config/locales/en.yml
|
|
164
|
-
- examples/rails_test_app/testapp/config/puma.rb
|
|
165
|
-
- examples/rails_test_app/testapp/config/routes.rb
|
|
166
|
-
- examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb
|
|
167
|
-
- examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb
|
|
168
|
-
- examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb
|
|
169
|
-
- examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb
|
|
170
|
-
- examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb
|
|
171
|
-
- examples/rails_test_app/testapp/db/schema.rb
|
|
172
|
-
- examples/rails_test_app/testapp/db/seeds.rb
|
|
173
|
-
- examples/rails_test_app/testapp/docker-compose.yml
|
|
174
|
-
- examples/rails_test_app/testapp/docker-entrypoint.sh
|
|
175
|
-
- examples/rails_test_app/testapp/lib/tasks/.keep
|
|
176
|
-
- examples/rails_test_app/testapp/log/.keep
|
|
177
|
-
- examples/rails_test_app/testapp/public/400.html
|
|
178
|
-
- examples/rails_test_app/testapp/public/404.html
|
|
179
|
-
- examples/rails_test_app/testapp/public/406-unsupported-browser.html
|
|
180
|
-
- examples/rails_test_app/testapp/public/422.html
|
|
181
|
-
- examples/rails_test_app/testapp/public/500.html
|
|
182
|
-
- examples/rails_test_app/testapp/public/icon.png
|
|
183
|
-
- examples/rails_test_app/testapp/public/icon.svg
|
|
184
|
-
- examples/rails_test_app/testapp/public/robots.txt
|
|
185
|
-
- examples/rails_test_app/testapp/script/.keep
|
|
186
|
-
- examples/rails_test_app/testapp/storage/.keep
|
|
187
|
-
- examples/rails_test_app/testapp/tmp/.keep
|
|
188
|
-
- examples/rails_test_app/testapp/tmp/pids/.keep
|
|
189
|
-
- examples/rails_test_app/testapp/tmp/storage/.keep
|
|
190
|
-
- examples/rails_test_app/testapp/vendor/.keep
|
|
191
132
|
- exe/debug-mcp
|
|
192
133
|
- exe/debug-rails
|
|
193
134
|
- lib/debug_mcp.rb
|
|
@@ -217,7 +158,9 @@ files:
|
|
|
217
158
|
- lib/debug_mcp/tools/list_paused_sessions.rb
|
|
218
159
|
- lib/debug_mcp/tools/next.rb
|
|
219
160
|
- lib/debug_mcp/tools/rails_info.rb
|
|
161
|
+
- lib/debug_mcp/tools/rails_mail_deliveries.rb
|
|
220
162
|
- lib/debug_mcp/tools/rails_model.rb
|
|
163
|
+
- lib/debug_mcp/tools/rails_recent_events.rb
|
|
221
164
|
- lib/debug_mcp/tools/rails_routes.rb
|
|
222
165
|
- lib/debug_mcp/tools/read_file.rb
|
|
223
166
|
- lib/debug_mcp/tools/remove_breakpoint.rb
|
data/examples/01_simple_bug.rb
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# シナリオ: 割引計算にバグがある
|
|
4
|
-
# 期待: 合計金額が1000円以上なら10%割引
|
|
5
|
-
# 実際: 割引が正しく適用されない
|
|
6
|
-
|
|
7
|
-
class Cart
|
|
8
|
-
attr_reader :items
|
|
9
|
-
|
|
10
|
-
def initialize
|
|
11
|
-
@items = []
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def add(name, price, quantity = 1)
|
|
15
|
-
@items << { name: name, price: price, quantity: quantity }
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def subtotal
|
|
19
|
-
@items.sum { |item| item[:price] * item[:quantity] }
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def discount_rate
|
|
23
|
-
subtotal > 1000 ? 0.1 : 0
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def total
|
|
27
|
-
sub = subtotal
|
|
28
|
-
discount = sub * discount_rate
|
|
29
|
-
sub + discount # BUG: should be sub - discount
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
cart = Cart.new
|
|
34
|
-
cart.add("りんご", 200, 3)
|
|
35
|
-
cart.add("バナナ", 150, 2)
|
|
36
|
-
cart.add("みかん", 100, 4)
|
|
37
|
-
|
|
38
|
-
debugger
|
|
39
|
-
|
|
40
|
-
puts "小計: #{cart.subtotal}円"
|
|
41
|
-
puts "割引率: #{cart.discount_rate * 100}%"
|
|
42
|
-
puts "合計: #{cart.total}円"
|
|
43
|
-
puts "期待される合計: #{cart.subtotal * (1 - cart.discount_rate)}円"
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# シナリオ: CSVデータ変換パイプラインで一部のレコードが消える
|
|
4
|
-
# ステップ実行で各段階のデータを追跡する
|
|
5
|
-
|
|
6
|
-
require "csv"
|
|
7
|
-
require "stringio"
|
|
8
|
-
|
|
9
|
-
csv_data = <<~CSV
|
|
10
|
-
id,name,age,score
|
|
11
|
-
1,田中太郎,28,85
|
|
12
|
-
2,鈴木花子,,92
|
|
13
|
-
3,佐藤次郎,35,
|
|
14
|
-
4,山田三郎,42,78
|
|
15
|
-
5,高橋四郎,-3,95
|
|
16
|
-
CSV
|
|
17
|
-
|
|
18
|
-
class DataPipeline
|
|
19
|
-
def initialize(csv_string)
|
|
20
|
-
@raw = CSV.parse(csv_string, headers: true)
|
|
21
|
-
@records = []
|
|
22
|
-
@errors = []
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def run
|
|
26
|
-
parse
|
|
27
|
-
validate
|
|
28
|
-
transform
|
|
29
|
-
{ records: @records, errors: @errors, stats: stats }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
private
|
|
33
|
-
|
|
34
|
-
def parse
|
|
35
|
-
@records = @raw.map do |row|
|
|
36
|
-
{
|
|
37
|
-
id: row["id"]&.to_i,
|
|
38
|
-
name: row["name"],
|
|
39
|
-
age: row["age"]&.to_i, # nil.to_i => 0
|
|
40
|
-
score: row["score"]&.to_i, # nil.to_i => 0
|
|
41
|
-
}
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def validate
|
|
46
|
-
@records.reject! do |r|
|
|
47
|
-
if r[:age] <= 0
|
|
48
|
-
@errors << { id: r[:id], reason: "invalid age: #{r[:age]}" }
|
|
49
|
-
true
|
|
50
|
-
elsif r[:score] <= 0
|
|
51
|
-
@errors << { id: r[:id], reason: "invalid score: #{r[:score]}" }
|
|
52
|
-
true
|
|
53
|
-
else
|
|
54
|
-
false
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def transform
|
|
60
|
-
avg = @records.sum { |r| r[:score] } / @records.size.to_f
|
|
61
|
-
@records.each do |r|
|
|
62
|
-
r[:grade] = case r[:score]
|
|
63
|
-
when 90.. then "A"
|
|
64
|
-
when 80.. then "B"
|
|
65
|
-
when 70.. then "C"
|
|
66
|
-
else "D"
|
|
67
|
-
end
|
|
68
|
-
r[:above_average] = r[:score] > avg
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def stats
|
|
73
|
-
{
|
|
74
|
-
total_input: @raw.size,
|
|
75
|
-
valid_records: @records.size,
|
|
76
|
-
error_count: @errors.size,
|
|
77
|
-
average_score: @records.sum { |r| r[:score] } / @records.size.to_f,
|
|
78
|
-
}
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
pipeline = DataPipeline.new(csv_data)
|
|
83
|
-
|
|
84
|
-
debugger
|
|
85
|
-
|
|
86
|
-
result = pipeline.run
|
|
87
|
-
|
|
88
|
-
puts "=== 結果 ==="
|
|
89
|
-
puts "有効レコード: #{result[:records].size}"
|
|
90
|
-
result[:records].each { |r| puts " #{r}" }
|
|
91
|
-
puts "エラー: #{result[:errors].size}"
|
|
92
|
-
result[:errors].each { |e| puts " #{e}" }
|
|
93
|
-
puts "統計: #{result[:stats]}"
|