debug-mcp 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e1fb7afee534aad7a8eefa7dcf23af611f4a4ca0b630ce15abb8be9e4ee1bdb
4
- data.tar.gz: ba299cabca9196b1e77ebf871891caf1452ea76c0f1b5010502875d65173a43e
3
+ metadata.gz: 6257424af8b8ba371cecbb8f76fa8b0b68b1bb7d087528b7d5a99b4f53cccf92
4
+ data.tar.gz: abaaef014089cfc1dfa20e653a5c86f0614b3de3c685fc0d19602fcc8a411edc
5
5
  SHA512:
6
- metadata.gz: '0935b47a3b99932f43cc0d43ef4bfdb41a386e1f8ead818643281f86e2c68d0b3457f94e6807d1d16743a356c335c6b08f9e4133abe6142b4fefd92531380360'
7
- data.tar.gz: 79a9660c468b25d384ddffcf7d6be41228dadd11b37a5cccc3e096a6cf43e6932b1392a755d796b109d8feea85f424042be57729222dbc363029dd29e40cf5d7
6
+ metadata.gz: 8a747e5eb9a03f257af96d135128b4ade00c64700ae8c7fe9c7dd7f93c8b025fa41dfc7d345bce78aa2544ab8af0e701329257205294b4e74c1682d23b323a93
7
+ data.tar.gz: db31877e159f7777f2fa4f242bc07440deb512d99e7eb929de36538ce5f5f879e5efe1b511e5838cdf7d4122f1ebd214176afa2411f3e105621c40038003eaf2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-05-14
4
+
5
+ ### Features
6
+
7
+ - **Structured Rails event capture in `trigger_request`** — The response now includes
8
+ a `Rails Events` section with SQL queries, rendered templates, cache operations,
9
+ enqueued jobs, and request lifecycle info, sourced from `ActiveSupport::Notifications`.
10
+ LLM agents can detect N+1 queries, verify cache effectiveness, and track side-effects
11
+ using structured data instead of parsing log text.
12
+
13
+ How it works: debug-mcp injects a subscriber into the Rails process via `evaluate_code`
14
+ at first use (idempotent, no gem install needed in the Rails app), tags HTTP requests
15
+ with an auto-generated `X-Request-Id`, and correlates the captured events back to the
16
+ triggering request. Events live in a per-process ring buffer (1000 events) protected
17
+ by a Mutex.
18
+
19
+ - **Source tagging to separate app execution from debugger inspection** — When
20
+ `evaluate_code` or `inspect_object` is used at a breakpoint, the AR queries they
21
+ fire run on the request thread. Those events are tagged `source: :debug_eval` so
22
+ they don't appear to be part of application execution. The default view shows only
23
+ `:request` events; pass `include_debug_eval: true` to `trigger_request` to see all.
24
+ Implemented via `Thread.current[:_debug_mcp_event_source]` with save/restore so
25
+ nested wrapping is safe.
26
+
27
+ - **New `trigger_request` options** — `event_limits` (per-category count overrides;
28
+ defaults `sql: 30, render: 20, cache: 20, job: unlimited, logger: 50`; pass `null`
29
+ to disable a limit) and `include_debug_eval` (boolean) for tuning the event output.
30
+
31
+ ### Bug Fixes (pre-release)
32
+
33
+ - **`SourceTagging.wrap` nested-safety** — The initial implementation saved the
34
+ prior Thread-local value in a local variable, which a nested wrap within the
35
+ same eval overwrote, causing the outer's `ensure` to restore the wrong value.
36
+ Switched to a Thread-local stack (`:_debug_mcp_event_source_stack`) so each
37
+ push/pop pair restores the correct prior source regardless of nesting depth.
38
+ Discovered during real-Rails verification before the 0.2.0 release.
39
+
40
+ ### Internal
41
+
42
+ - New modules: `DebugMcp::NotificationsSubscriber`, `DebugMcp::EventFormatter`,
43
+ `DebugMcp::SourceTagging`.
44
+ - `evaluate_code` and `inspect_object` now wrap user expressions with `SourceTagging.wrap`.
45
+
3
46
  ## Renamed: `girb-mcp` → `debug-mcp` (2026-04-28)
4
47
 
5
48
  This gem was previously released on RubyGems as `girb-mcp`. It has been renamed to
data/README.ja.md CHANGED
@@ -214,6 +214,55 @@ Railsプロセスを検出すると自動的に登録されます。
214
214
  | `rails_routes` | ルーティング一覧(verb, path, controller#action)、コントローラ・パスでフィルタ可能 |
215
215
  | `rails_model` | モデル構造:カラム・アソシエーション・バリデーション・enum・スコープを表示 |
216
216
 
217
+ ## Rails イベントキャプチャ
218
+
219
+ Rails アプリへ `trigger_request` を実行すると、レスポンスにリクエスト中の挙動を構造化した `Rails Events` セクションが付加されます。
220
+
221
+ - SQL クエリ (実行時間、bind 値、query name、cached フラグ)
222
+ - レンダリング (template / partial / collection)
223
+ - キャッシュ操作 (read/write/fetch_hit、hit/miss)
224
+ - enqueue された ActiveJob
225
+ - リクエストのライフサイクル (controller#action、status、view/db runtime)
226
+
227
+ debug-mcp が初回の `trigger_request` 時に `ActiveSupport::Notifications` の subscriber を Rails プロセスに動的注入することで実現しています。Rails アプリ側に gem 追加は不要です。
228
+
229
+ ### 出力例
230
+
231
+ ```
232
+ Agent: trigger_request(method: "GET", url: "http://localhost:3000/users/1")
233
+
234
+ HTTP 200 OK
235
+ {...response body...}
236
+
237
+ --- Rails Events ---
238
+ ## Request
239
+ GET /users/1 → UsersController#show
240
+ Status: 200 — total 35.4ms (view 12.5ms, db 4.2ms)
241
+
242
+ ## SQL (1 query)
243
+ 1. (0.66ms) User Load SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
244
+ ```
245
+
246
+ ### フィルタリング: アプリ実行と debugger 操作の分離
247
+
248
+ breakpoint 停止中に `evaluate_code` や `inspect_object` を実行すると、それらが発行した AR クエリもリクエストスレッド上で動くためバッファに入ります。debug-mcp はそれらに `source: :debug_eval` タグを付けるので、デフォルト表示からは除外され、アプリ自身の挙動だけが見える状態になります。
249
+
250
+ - **デフォルト**: `:debug_eval` のイベントを非表示。アプリ自身の動作だけを表示。
251
+ - **`include_debug_eval: true`** を `trigger_request` に渡す: デバッガが触ったクエリも含めて全件表示。
252
+
253
+ ### 出力量の調整
254
+
255
+ `trigger_request` の `event_limits` オプションでカテゴリ別の上限を上書きできます。
256
+
257
+ ```
258
+ trigger_request(
259
+ method: "GET", url: "...",
260
+ event_limits: { sql: 100, render: null } # null は上限なし
261
+ )
262
+ ```
263
+
264
+ デフォルト: `sql=30`, `render=20`, `cache=20`, `job=無制限`, `logger=50`。
265
+
217
266
  ## ワークフロー例
218
267
 
219
268
  ### Rubyスクリプトのデバッグ
data/README.md CHANGED
@@ -215,6 +215,55 @@ These tools are automatically registered when a Rails process is detected.
215
215
  | `rails_routes` | Show routes (verb, path, controller#action), filterable by controller or path |
216
216
  | `rails_model` | Show model structure: columns, associations, validations, enums, scopes |
217
217
 
218
+ ## Rails event capture
219
+
220
+ When `trigger_request` is used against a Rails app, the response includes a structured `Rails Events` section that surfaces what happened inside the request:
221
+
222
+ - SQL queries (duration, bind values, query name, cached flag)
223
+ - Renders (templates, partials, collections)
224
+ - Cache operations (read/write/fetch_hit, hit/miss)
225
+ - Enqueued ActiveJob jobs
226
+ - Request lifecycle (controller#action, status, view/db runtime)
227
+
228
+ This is captured via an `ActiveSupport::Notifications` subscriber that debug-mcp injects into the Rails process at first use — no gem installation in your Rails app required.
229
+
230
+ ### Example output
231
+
232
+ ```
233
+ Agent: trigger_request(method: "GET", url: "http://localhost:3000/users/1")
234
+
235
+ HTTP 200 OK
236
+ {...response body...}
237
+
238
+ --- Rails Events ---
239
+ ## Request
240
+ GET /users/1 → UsersController#show
241
+ Status: 200 — total 35.4ms (view 12.5ms, db 4.2ms)
242
+
243
+ ## SQL (1 query)
244
+ 1. (0.66ms) User Load SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
245
+ ```
246
+
247
+ ### Filtering: separating app execution from debugger inspection
248
+
249
+ When you call `evaluate_code` or `inspect_object` at a breakpoint, any AR queries those make run on the request thread and would otherwise look like application SQL. debug-mcp tags them with `source: :debug_eval` so the default view stays clean.
250
+
251
+ - **Default**: `:debug_eval` events are hidden — what you see is the app's own work.
252
+ - **`include_debug_eval: true`** on `trigger_request`: include them, useful for seeing what the debugger touched.
253
+
254
+ ### Tuning the output volume
255
+
256
+ `trigger_request` accepts an `event_limits` object to override per-category caps:
257
+
258
+ ```
259
+ trigger_request(
260
+ method: "GET", url: "...",
261
+ event_limits: { sql: 100, render: null } # null disables the limit
262
+ )
263
+ ```
264
+
265
+ Defaults: `sql=30`, `render=20`, `cache=20`, `job=unlimited`, `logger=50`.
266
+
218
267
  ## Workflows
219
268
 
220
269
  ### Debug a Ruby script
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugMcp
4
+ module EventFormatter
5
+ DEFAULT_LIMITS = {
6
+ sql: 30,
7
+ render: 20,
8
+ cache: 20,
9
+ job: nil, # nil = no limit
10
+ logger: 50,
11
+ }.freeze
12
+
13
+ MAX_SQL_CHARS = 500
14
+ SQL_HEAD = 300
15
+ SQL_TAIL = 100
16
+ MAX_CACHE_KEY_CHARS = 200
17
+ MAX_JOB_ARG_CHARS = 300
18
+
19
+ class << self
20
+ # Format an array of event hashes (as returned by NotificationsSubscriber.fetch_*)
21
+ # into a Markdown-flavored structured text block.
22
+ # Returns nil if events is empty (after filtering).
23
+ #
24
+ # By default, events with source: :debug_eval are excluded — these are AR
25
+ # queries / renders / etc. fired by debug-mcp's own evaluate_code calls
26
+ # during breakpoint inspection (ADR-0003). Pass include_debug_eval: true
27
+ # to keep them.
28
+ def format(events, limits: DEFAULT_LIMITS, include_debug_eval: false)
29
+ return nil if events.nil? || events.empty?
30
+
31
+ filtered = include_debug_eval ? events : events.reject { |e| e[:source].to_s == "debug_eval" }
32
+ return nil if filtered.empty?
33
+
34
+ grouped = group_events(filtered)
35
+ sections = []
36
+
37
+ sections << format_controller(grouped[:controller])
38
+ sections << format_sql(grouped[:sql], limit: limits[:sql])
39
+ sections << format_renders(grouped[:render], limit: limits[:render])
40
+ sections << format_cache(grouped[:cache], limit: limits[:cache])
41
+ sections << format_jobs(grouped[:job], limit: limits[:job])
42
+
43
+ sections.compact.join("\n\n")
44
+ end
45
+
46
+ private
47
+
48
+ def group_events(events)
49
+ groups = { controller: [], sql: [], render: [], cache: [], job: [] }
50
+ events.each do |e|
51
+ name = e[:name].to_s
52
+ case name
53
+ when "sql.active_record" then groups[:sql] << e
54
+ when /\Arender_/ then groups[:render] << e
55
+ when /\Acache_/ then groups[:cache] << e
56
+ when "enqueue.active_job" then groups[:job] << e
57
+ when "start_processing.action_controller", "process_action.action_controller"
58
+ groups[:controller] << e
59
+ end
60
+ end
61
+ groups
62
+ end
63
+
64
+ def format_controller(events)
65
+ return nil if events.empty?
66
+ finish = events.find { |e| e[:name] == "process_action.action_controller" }
67
+ start = events.find { |e| e[:name] == "start_processing.action_controller" }
68
+ target = finish || start
69
+ return nil unless target
70
+
71
+ data = target[:data] || {}
72
+ lines = ["## Request"]
73
+ lines << "#{data[:method]} #{data[:path]} → #{data[:controller]}##{data[:action]}"
74
+ if finish
75
+ status = data[:status]
76
+ dur = finish[:duration_ms]
77
+ extras = []
78
+ extras << "view #{data[:view_runtime].round(1)}ms" if data[:view_runtime]
79
+ extras << "db #{data[:db_runtime].round(1)}ms" if data[:db_runtime]
80
+ extra_str = extras.any? ? " (#{extras.join(", ")})" : ""
81
+ lines << "Status: #{status} — total #{dur}ms#{extra_str}"
82
+ end
83
+ lines.join("\n")
84
+ end
85
+
86
+ def format_sql(events, limit:)
87
+ return nil if events.empty?
88
+ shown, truncated = apply_limit(events, limit)
89
+ lines = ["## SQL (#{events.size} #{events.size == 1 ? "query" : "queries"})"]
90
+ shown.each_with_index do |e, i|
91
+ d = e[:data] || {}
92
+ dur = e[:duration_ms]
93
+ cached = d[:cached] ? " [cached]" : ""
94
+ name = d[:query_name].to_s.empty? ? "" : " #{d[:query_name]}"
95
+ sql_text = truncate_sql(d[:sql].to_s)
96
+ lines << "#{i + 1}. (#{dur}ms)#{cached}#{name} #{sql_text}"
97
+ if d[:binds].is_a?(Array) && d[:binds].any?
98
+ lines << " binds: #{d[:binds].join(", ")}"
99
+ end
100
+ end
101
+ lines << "... and #{truncated} more (limit=#{limit})" if truncated > 0
102
+ lines.join("\n")
103
+ end
104
+
105
+ def format_renders(events, limit:)
106
+ return nil if events.empty?
107
+ shown, truncated = apply_limit(events, limit)
108
+ lines = ["## Renders (#{events.size})"]
109
+ shown.each_with_index do |e, i|
110
+ d = e[:data] || {}
111
+ kind = e[:name].to_s.sub("render_", "").sub(".action_view", "")
112
+ dur = e[:duration_ms]
113
+ identifier = d[:identifier].to_s
114
+ # Shorten absolute paths to just the relative portion when possible
115
+ identifier = identifier.sub(%r{\A.*?/app/views/}, "app/views/")
116
+ extras = []
117
+ extras << "layout=#{d[:layout]}" if d[:layout] && !d[:layout].to_s.empty?
118
+ extras << "count=#{d[:count]}" if d[:count]
119
+ extra_str = extras.any? ? " (#{extras.join(", ")})" : ""
120
+ lines << "#{i + 1}. [#{kind}] #{identifier} — #{dur}ms#{extra_str}"
121
+ end
122
+ lines << "... and #{truncated} more (limit=#{limit})" if truncated > 0
123
+ lines.join("\n")
124
+ end
125
+
126
+ def format_cache(events, limit:)
127
+ return nil if events.empty?
128
+ shown, truncated = apply_limit(events, limit)
129
+ lines = ["## Cache (#{events.size})"]
130
+ shown.each_with_index do |e, i|
131
+ d = e[:data] || {}
132
+ op = e[:name].to_s.sub("cache_", "").sub(".active_support", "")
133
+ hit_marker = d[:hit] == true ? " hit" : d[:hit] == false ? " miss" : ""
134
+ key = d[:key].to_s[0, MAX_CACHE_KEY_CHARS]
135
+ lines << "#{i + 1}. [#{op}#{hit_marker}] #{key} (#{e[:duration_ms]}ms)"
136
+ end
137
+ lines << "... and #{truncated} more (limit=#{limit})" if truncated > 0
138
+ lines.join("\n")
139
+ end
140
+
141
+ def format_jobs(events, limit:)
142
+ return nil if events.empty?
143
+ shown, truncated = apply_limit(events, limit)
144
+ lines = ["## Enqueued Jobs (#{events.size})"]
145
+ shown.each_with_index do |e, i|
146
+ d = e[:data] || {}
147
+ klass = d[:job_class] || "(unknown)"
148
+ queue = d[:queue_name] ? " [#{d[:queue_name]}]" : ""
149
+ args = (d[:arguments] || []).join(", ")[0, MAX_JOB_ARG_CHARS]
150
+ arg_str = args.empty? ? "" : " args=#{args}"
151
+ lines << "#{i + 1}. #{klass}#{queue}#{arg_str}"
152
+ end
153
+ lines << "... and #{truncated} more (limit=#{limit})" if truncated > 0
154
+ lines.join("\n")
155
+ end
156
+
157
+ # Apply an item count limit. Returns [shown_array, truncated_count].
158
+ # Pass limit=nil for no limit.
159
+ def apply_limit(events, limit)
160
+ return [events, 0] if limit.nil? || events.size <= limit
161
+ [events.first(limit), events.size - limit]
162
+ end
163
+
164
+ def truncate_sql(sql)
165
+ return sql if sql.length <= MAX_SQL_CHARS
166
+ head = sql[0, SQL_HEAD]
167
+ tail = sql[-SQL_TAIL, SQL_TAIL]
168
+ "#{head} ... [truncated #{sql.length - SQL_HEAD - SQL_TAIL} chars] ... #{tail}"
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require_relative "rails_helper"
6
+
7
+ module DebugMcp
8
+ module NotificationsSubscriber
9
+ BUFFER_MAX = 1000
10
+
11
+ SUBSCRIBED_EVENTS = %w[
12
+ sql.active_record
13
+ render_template.action_view
14
+ render_partial.action_view
15
+ render_collection.action_view
16
+ cache_read.active_support
17
+ cache_write.active_support
18
+ cache_fetch_hit.active_support
19
+ cache_generate.active_support
20
+ cache_delete.active_support
21
+ enqueue.active_job
22
+ start_processing.action_controller
23
+ process_action.action_controller
24
+ ].freeze
25
+
26
+ INJECTION_CODE = <<~RUBY
27
+ unless defined?(::DebugMcpNotificationsBuffer)
28
+ module ::DebugMcpNotificationsBuffer
29
+ BUFFER_MAX = #{BUFFER_MAX}
30
+ SUBSCRIBED_EVENTS = #{SUBSCRIBED_EVENTS.inspect}.freeze
31
+
32
+ @buffer = []
33
+ @subscriptions = []
34
+ @mutex = Mutex.new
35
+
36
+ class << self
37
+ attr_reader :buffer, :subscriptions
38
+
39
+ def push(event)
40
+ @mutex.synchronize do
41
+ @buffer << event
42
+ @buffer.shift while @buffer.size > BUFFER_MAX
43
+ end
44
+ end
45
+
46
+ def fetch_by_request_id(request_id)
47
+ @mutex.synchronize { @buffer.select { |e| e[:request_id] == request_id } }
48
+ end
49
+
50
+ def fetch_since(timestamp)
51
+ @mutex.synchronize { @buffer.select { |e| e[:timestamp] >= timestamp } }
52
+ end
53
+
54
+ def clear
55
+ @mutex.synchronize { @buffer.clear }
56
+ end
57
+
58
+ def install
59
+ return if @subscriptions.any?
60
+
61
+ callback = lambda do |name, started, finished, _id, payload|
62
+ req_id = extract_request_id(name, payload)
63
+ src = Thread.current[:_debug_mcp_event_source] || :request
64
+ push({
65
+ name: name,
66
+ timestamp: started.to_f,
67
+ duration_ms: ((finished.to_f - started.to_f) * 1000).round(2),
68
+ request_id: req_id,
69
+ source: src,
70
+ data: sanitize_payload(name, payload),
71
+ })
72
+ Thread.current[:_debug_mcp_request_id] = nil if name == "process_action.action_controller"
73
+ rescue StandardError
74
+ # never raise from subscriber callback
75
+ end
76
+
77
+ SUBSCRIBED_EVENTS.each do |event_name|
78
+ @subscriptions << ::ActiveSupport::Notifications.subscribe(event_name, &callback)
79
+ end
80
+ end
81
+
82
+ def uninstall
83
+ @subscriptions.each { |s| ::ActiveSupport::Notifications.unsubscribe(s) }
84
+ @subscriptions.clear
85
+ end
86
+
87
+ def extract_request_id(name, payload)
88
+ return nil unless payload.is_a?(Hash)
89
+ req_id = payload[:request_id]
90
+ if name == "start_processing.action_controller" && payload[:headers]
91
+ h = payload[:headers]
92
+ req_id ||= safe_header_lookup(h, "action_dispatch.request_id")
93
+ req_id ||= safe_header_lookup(h, "HTTP_X_REQUEST_ID")
94
+ Thread.current[:_debug_mcp_request_id] = req_id if req_id
95
+ end
96
+ req_id || Thread.current[:_debug_mcp_request_id]
97
+ end
98
+
99
+ def safe_header_lookup(headers, key)
100
+ headers[key]
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ def sanitize_payload(name, payload)
106
+ return {} unless payload.is_a?(Hash)
107
+ case name
108
+ when "sql.active_record"
109
+ {
110
+ sql: payload[:sql].to_s,
111
+ query_name: payload[:name].to_s,
112
+ cached: payload[:cached] ? true : false,
113
+ binds: safe_binds(payload[:type_casted_binds] || payload[:binds]),
114
+ }
115
+ when /\\Arender_/
116
+ {
117
+ identifier: payload[:identifier].to_s,
118
+ layout: payload[:layout]&.to_s,
119
+ count: payload[:count],
120
+ }
121
+ when /\\Acache_/
122
+ {
123
+ key: payload[:key].to_s[0, 200],
124
+ hit: payload[:hit],
125
+ store: payload[:store]&.to_s,
126
+ }
127
+ when "enqueue.active_job"
128
+ job = payload[:job]
129
+ {
130
+ job_class: (job.class.name rescue nil),
131
+ job_id: (job.respond_to?(:job_id) ? job.job_id : nil),
132
+ queue_name: (job.respond_to?(:queue_name) ? job.queue_name : nil),
133
+ arguments: (job.respond_to?(:arguments) ? job.arguments : []).map { |a| safe_inspect(a, 100) },
134
+ }
135
+ when "start_processing.action_controller", "process_action.action_controller"
136
+ {
137
+ controller: payload[:controller],
138
+ action: payload[:action],
139
+ method: payload[:method],
140
+ path: payload[:path],
141
+ format: payload[:format].to_s,
142
+ status: payload[:status],
143
+ view_runtime: payload[:view_runtime],
144
+ db_runtime: payload[:db_runtime],
145
+ }
146
+ else
147
+ {}
148
+ end
149
+ rescue StandardError => e
150
+ { error: "payload_sanitize_failed: \#{e.class}" }
151
+ end
152
+
153
+ def safe_binds(binds)
154
+ return [] unless binds.respond_to?(:each)
155
+ binds.map { |b| safe_inspect(b, 100) }
156
+ rescue StandardError
157
+ []
158
+ end
159
+
160
+ def safe_inspect(obj, limit)
161
+ s = obj.inspect
162
+ s.length > limit ? s[0, limit] + "..." : s
163
+ rescue StandardError
164
+ "<uninspectable>"
165
+ end
166
+ end
167
+ end
168
+ ::DebugMcpNotificationsBuffer.install
169
+ end
170
+ RUBY
171
+
172
+ class << self
173
+ # Inject the subscriber into the Rails process. Idempotent.
174
+ # Returns true on success, false otherwise.
175
+ def install(client)
176
+ return false unless RailsHelper.rails?(client)
177
+
178
+ encoded = Base64.strict_encode64(INJECTION_CODE)
179
+ cmd = "p begin; require 'base64'; eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8')); " \
180
+ ":debug_mcp_subscriber_ok; rescue => __e; \"\#{__e.class}: \#{__e.message}\"; end"
181
+ result = client.send_command(cmd)
182
+ result.include?("debug_mcp_subscriber_ok")
183
+ rescue DebugMcp::Error
184
+ false
185
+ end
186
+
187
+ # Remove the subscriber. Best-effort; returns nil on error.
188
+ def uninstall(client)
189
+ client.send_command(
190
+ "::DebugMcpNotificationsBuffer.uninstall if defined?(::DebugMcpNotificationsBuffer)",
191
+ )
192
+ rescue DebugMcp::Error
193
+ nil
194
+ end
195
+
196
+ # Fetch events for a request_id. Returns array of event hashes (symbolized keys).
197
+ def fetch_by_request_id(client, request_id)
198
+ return [] unless request_id
199
+
200
+ code = "puts(defined?(::DebugMcpNotificationsBuffer) ? " \
201
+ "::DebugMcpNotificationsBuffer.fetch_by_request_id(#{request_id.inspect}).to_json : '[]')"
202
+ result = client.send_command(code)
203
+ parse_json_array(result)
204
+ rescue DebugMcp::Error
205
+ []
206
+ end
207
+
208
+ # Fetch events fired at-or-after the given timestamp.
209
+ def fetch_since(client, timestamp)
210
+ code = "puts(defined?(::DebugMcpNotificationsBuffer) ? " \
211
+ "::DebugMcpNotificationsBuffer.fetch_since(#{timestamp.to_f}).to_json : '[]')"
212
+ result = client.send_command(code)
213
+ parse_json_array(result)
214
+ rescue DebugMcp::Error
215
+ []
216
+ end
217
+
218
+ private
219
+
220
+ def parse_json_array(text)
221
+ return [] unless text
222
+ text.each_line do |line|
223
+ stripped = line.strip
224
+ next unless stripped.start_with?("[")
225
+ begin
226
+ parsed = JSON.parse(stripped, symbolize_names: true)
227
+ return parsed if parsed.is_a?(Array)
228
+ rescue JSON::ParserError
229
+ next
230
+ end
231
+ end
232
+ []
233
+ end
234
+ end
235
+ end
236
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "base64"
4
+ require_relative "source_tagging"
4
5
 
5
6
  module DebugMcp
6
7
  module RailsHelper
@@ -63,6 +64,11 @@ module DebugMcp
63
64
  # result by the debug gem, which works even in signal trap context.
64
65
  # Returns nil if the result is nil or evaluation fails.
65
66
  def eval_expr(client, expr)
67
+ # Note: internal probes use simple expressions (Rails.root, Rails.env,
68
+ # Dir.pwd, etc.) that don't fire ActiveSupport::Notifications events,
69
+ # so SourceTagging.wrap isn't applied here. Tools that take arbitrary
70
+ # user expressions (evaluate_code, inspect_object) handle tagging
71
+ # themselves at the call site.
66
72
  result = client.send_command("p #{expr}")
67
73
  cleaned = result.strip.sub(/\A=> /, "")
68
74
  return nil if cleaned == "nil" || cleaned.empty?
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugMcp
4
+ module SourceTagging
5
+ SOURCE_KEY = ":_debug_mcp_event_source"
6
+ STACK_KEY = ":_debug_mcp_event_source_stack"
7
+ DEBUG_EVAL_SOURCE = ":debug_eval"
8
+
9
+ class << self
10
+ # Wrap a Ruby expression so ActiveSupport::Notifications events fired
11
+ # during its evaluation are tagged with source: :debug_eval.
12
+ #
13
+ # Uses a Thread-local stack rather than a local variable so nested
14
+ # wraps within a single eval are safe — each push/pop pair restores
15
+ # the correct previous value even when wraps share the same call frame.
16
+ #
17
+ # The wrapped expression evaluates to the same value as the original.
18
+ def wrap(code)
19
+ "begin; " \
20
+ "(::Thread.current[#{STACK_KEY}] ||= []) << ::Thread.current[#{SOURCE_KEY}]; " \
21
+ "::Thread.current[#{SOURCE_KEY}]=#{DEBUG_EVAL_SOURCE}; " \
22
+ "(#{code}); " \
23
+ "ensure ::Thread.current[#{SOURCE_KEY}]=::Thread.current[#{STACK_KEY}].pop; end"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -228,13 +228,19 @@ module DebugMcp
228
228
  # gem protocol is line-based) or non-ASCII characters (to avoid
229
229
  # encoding conflicts on the socket).
230
230
  def build_eval_command(code)
231
+ # Tag any ActiveSupport::Notifications events fired during this eval
232
+ # so trigger_request can filter them out (ADR-0003).
233
+ # SourceTagging.wrap uses save/restore so nested wraps don't clobber
234
+ # any outer source value.
231
235
  if code.include?("\n") || !code.ascii_only?
232
236
  encoded = Base64.strict_encode64(code.encode(Encoding::UTF_8))
237
+ inner = "eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8'), binding)"
233
238
  "$__debug_mcp_err=nil; pp(begin; require 'base64'; " \
234
- "eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8'), binding); " \
239
+ "#{DebugMcp::SourceTagging.wrap(inner)}; " \
235
240
  'rescue => __e; $__debug_mcp_err="#{__e.class}: #{__e.message}"; nil; end)'
236
241
  else
237
- "$__debug_mcp_err=nil; pp(begin; (#{code}); " \
242
+ "$__debug_mcp_err=nil; pp(begin; " \
243
+ "#{DebugMcp::SourceTagging.wrap(code)}; " \
238
244
  'rescue => __e; $__debug_mcp_err="#{__e.class}: #{__e.message}"; nil; end)'
239
245
  end
240
246
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "mcp"
4
4
  require_relative "../pending_http_helper"
5
+ require_relative "../source_tagging"
5
6
 
6
7
  module DebugMcp
7
8
  module Tools
@@ -38,16 +39,19 @@ module DebugMcp
38
39
 
39
40
  parts = []
40
41
 
42
+ # User-supplied expressions can hit ActiveRecord and fire Notifications
43
+ # events. Tag them as :debug_eval so trigger_request can filter them
44
+ # out (ADR-0003).
45
+
41
46
  # RT 1: Get the pretty-printed value (primary - if this fails, expression is invalid)
42
- value_output = client.send_command("pp #{expression}")
47
+ value_output = client.send_command("pp(#{DebugMcp::SourceTagging.wrap(expression)})")
43
48
  parts << "Value:\n#{value_output}"
44
49
 
45
50
  # RT 2: Get class + instance variables (+ class variables if Module) in a single command
46
51
  begin
47
- meta_output = client.send_command(
48
- "p [(#{expression}).class.to_s, (#{expression}).instance_variables, " \
49
- "(#{expression}).is_a?(Module) ? (#{expression}).class_variables : nil]",
50
- )
52
+ meta_expr = "[(#{expression}).class.to_s, (#{expression}).instance_variables, " \
53
+ "(#{expression}).is_a?(Module) ? (#{expression}).class_variables : nil]"
54
+ meta_output = client.send_command("p(#{DebugMcp::SourceTagging.wrap(meta_expr)})")
51
55
  class_name, ivars, cvars = parse_meta(meta_output)
52
56
  parts << "Class: #{class_name}" if class_name
53
57
  parts << "Instance variables: #{ivars}" if ivars
@@ -55,10 +59,9 @@ module DebugMcp
55
59
  # RT 3: Get class variable values (only for Module/Class with class variables)
56
60
  if cvars && cvars != "[]"
57
61
  begin
58
- cvar_values = client.send_command(
59
- "pp Hash[(#{expression}).class_variables.map{|v|" \
60
- "[v,begin;(#{expression}).class_variable_get(v);rescue;'(error)';end]}]",
61
- )
62
+ cvar_expr = "Hash[(#{expression}).class_variables.map{|v|" \
63
+ "[v,begin;(#{expression}).class_variable_get(v);rescue;'(error)';end]}]"
64
+ cvar_values = client.send_command("pp(#{DebugMcp::SourceTagging.wrap(cvar_expr)})")
62
65
  parts << "Class variables:\n#{cvar_values}"
63
66
  rescue DebugMcp::TimeoutError
64
67
  parts << "Class variables: #{cvars}"
@@ -2,9 +2,12 @@
2
2
 
3
3
  require "mcp"
4
4
  require "net/http"
5
+ require "securerandom"
5
6
  require "uri"
6
7
  require "json"
7
8
  require_relative "../rails_helper"
9
+ require_relative "../notifications_subscriber"
10
+ require_relative "../event_formatter"
8
11
 
9
12
  module DebugMcp
10
13
  module Tools
@@ -63,6 +66,21 @@ module DebugMcp
63
66
  type: "string",
64
67
  description: "Debug session ID to monitor for breakpoint hits (uses default if omitted)",
65
68
  },
69
+ event_limits: {
70
+ type: "object",
71
+ description: "Override the per-category event count limits in the structured 'Rails Events' " \
72
+ "section. Partial overrides supported. Keys: sql, render, cache, job, logger. " \
73
+ "Pass null for a key to disable its limit. " \
74
+ "Defaults: sql=30, render=20, cache=20, job=unlimited, logger=50. " \
75
+ "Example: {\"sql\": 100, \"render\": null}",
76
+ },
77
+ include_debug_eval: {
78
+ type: "boolean",
79
+ description: "Include events triggered by debug-mcp's own evaluate_code / inspect_object calls " \
80
+ "(tagged source: :debug_eval). Defaults to false — debugger-driven SQL is hidden " \
81
+ "to keep the request view focused on application execution. " \
82
+ "Set true when you want to see what the debugger touched.",
83
+ },
66
84
  },
67
85
  required: ["method", "url"],
68
86
  )
@@ -71,9 +89,11 @@ module DebugMcp
71
89
  MAX_LOG_BYTES = 4000
72
90
 
73
91
  def call(method:, url:, headers: {}, body: nil, cookies: nil, skip_csrf: nil,
74
- timeout: nil, session_id: nil, server_context:)
92
+ timeout: nil, session_id: nil, event_limits: nil, include_debug_eval: false,
93
+ server_context:)
75
94
  manager = server_context[:session_manager]
76
95
  timeout_sec = timeout || DEFAULT_TIMEOUT
96
+ formatter_limits = resolve_event_limits(event_limits)
77
97
 
78
98
  # Auto-detect Content-Type if body is present and no Content-Type header set
79
99
  headers = (headers || {}).dup
@@ -92,10 +112,18 @@ module DebugMcp
92
112
  end
93
113
  end
94
114
 
115
+ # Inject a request ID so we can correlate Notifications events with this request.
116
+ # Rails (ActionDispatch::RequestId middleware) honors X-Request-Id if present.
117
+ request_id = SecureRandom.uuid
118
+ unless headers.any? { |k, _| k.to_s.downcase == "x-request-id" }
119
+ headers["X-Request-Id"] = request_id
120
+ end
121
+
95
122
  # CSRF handling: disable forgery protection for non-GET requests on Rails
96
123
  csrf_disabled = false
97
124
  client = nil
98
125
  log_capture = nil
126
+ subscriber_ready = false
99
127
  begin
100
128
  client = manager.client(session_id)
101
129
  # Recover paused state if a previous timeout left @paused=false
@@ -112,6 +140,8 @@ module DebugMcp
112
140
  if method != "GET" && should_disable_csrf?(skip_csrf, client)
113
141
  csrf_disabled = temporarily_disable_csrf(client)
114
142
  end
143
+ # Inject the Notifications subscriber lazily (idempotent).
144
+ subscriber_ready = NotificationsSubscriber.install(client)
115
145
  # Snapshot log file position before request for Rails log capture
116
146
  log_capture = start_log_capture(client)
117
147
  end
@@ -126,7 +156,10 @@ module DebugMcp
126
156
  handle_without_session(method, url, headers, body, timeout_sec)
127
157
  end
128
158
 
129
- append_captured_logs(response, log_capture)
159
+ response = append_captured_logs(response, log_capture)
160
+ append_notifications_events(response, client, request_id, subscriber_ready,
161
+ limits: formatter_limits,
162
+ include_debug_eval: include_debug_eval)
130
163
  ensure
131
164
  # Only restore CSRF when the process is paused (at a breakpoint).
132
165
  # If the process is running (interrupted/timeout), sending commands
@@ -433,6 +466,51 @@ module DebugMcp
433
466
  nil
434
467
  end
435
468
 
469
+ # Query the Notifications subscriber buffer for events tied to this request
470
+ # and append a structured section to the response. Best-effort: returns the
471
+ # response unchanged if the subscriber is unavailable or the client is not
472
+ # paused (we cannot send_command on a running process).
473
+ def append_notifications_events(response, client, request_id, subscriber_ready,
474
+ limits: EventFormatter::DEFAULT_LIMITS, include_debug_eval: false)
475
+ return response unless subscriber_ready
476
+ return response unless client&.connected? && client.paused
477
+
478
+ events = NotificationsSubscriber.fetch_by_request_id(client, request_id)
479
+ return response if events.nil? || events.empty?
480
+
481
+ formatted = EventFormatter.format(events, limits: limits, include_debug_eval: include_debug_eval)
482
+ return response if formatted.nil? || formatted.empty?
483
+
484
+ existing = response.content.first
485
+ return response unless existing.is_a?(Hash) && existing[:type] == "text"
486
+
487
+ updated_text = existing[:text] + "\n\n--- Rails Events ---\n" + formatted
488
+ MCP::Tool::Response.new([{ type: "text", text: updated_text }])
489
+ rescue StandardError
490
+ response
491
+ end
492
+
493
+ # Merge user-supplied event_limits hash into the DEFAULT_LIMITS.
494
+ # Accepts both symbol and string keys. Returns a fully-populated hash
495
+ # suitable for EventFormatter.format.
496
+ def resolve_event_limits(overrides)
497
+ return EventFormatter::DEFAULT_LIMITS if overrides.nil? || overrides.empty?
498
+
499
+ result = EventFormatter::DEFAULT_LIMITS.dup
500
+ overrides.each do |k, v|
501
+ key = k.to_sym
502
+ next unless result.key?(key)
503
+ # Accept nil (no limit), integers, and other numerics; coerce strings if possible.
504
+ result[key] = case v
505
+ when nil then nil
506
+ when Integer then v
507
+ when String then (Integer(v, exception: false) || result[key])
508
+ else result[key]
509
+ end
510
+ end
511
+ result.freeze
512
+ end
513
+
436
514
  # Read new log entries since the snapshot and append to the response.
437
515
  def append_captured_logs(response, log_capture)
438
516
  return response unless log_capture
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DebugMcp
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/debug_mcp.rb CHANGED
@@ -6,6 +6,9 @@ require_relative "debug_mcp/session_manager"
6
6
  require_relative "debug_mcp/exit_message_builder"
7
7
  require_relative "debug_mcp/stop_event_annotator"
8
8
  require_relative "debug_mcp/tcp_session_discovery"
9
+ require_relative "debug_mcp/source_tagging"
10
+ require_relative "debug_mcp/event_formatter"
11
+ require_relative "debug_mcp/notifications_subscriber"
9
12
  require_relative "debug_mcp/server"
10
13
 
11
14
  module DebugMcp
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.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rira100000000
@@ -194,11 +194,14 @@ files:
194
194
  - lib/debug_mcp/client_cleanup.rb
195
195
  - lib/debug_mcp/code_safety_analyzer.rb
196
196
  - lib/debug_mcp/debug_client.rb
197
+ - lib/debug_mcp/event_formatter.rb
197
198
  - lib/debug_mcp/exit_message_builder.rb
199
+ - lib/debug_mcp/notifications_subscriber.rb
198
200
  - lib/debug_mcp/pending_http_helper.rb
199
201
  - lib/debug_mcp/rails_helper.rb
200
202
  - lib/debug_mcp/server.rb
201
203
  - lib/debug_mcp/session_manager.rb
204
+ - lib/debug_mcp/source_tagging.rb
202
205
  - lib/debug_mcp/stop_event_annotator.rb
203
206
  - lib/debug_mcp/tcp_session_discovery.rb
204
207
  - lib/debug_mcp/tools/connect.rb