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 +4 -4
- data/CHANGELOG.md +43 -0
- data/README.ja.md +49 -0
- data/README.md +49 -0
- data/lib/debug_mcp/event_formatter.rb +172 -0
- data/lib/debug_mcp/notifications_subscriber.rb +236 -0
- data/lib/debug_mcp/rails_helper.rb +6 -0
- data/lib/debug_mcp/source_tagging.rb +27 -0
- data/lib/debug_mcp/tools/evaluate_code.rb +8 -2
- data/lib/debug_mcp/tools/inspect_object.rb +12 -9
- data/lib/debug_mcp/tools/trigger_request.rb +80 -2
- data/lib/debug_mcp/version.rb +1 -1
- data/lib/debug_mcp.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6257424af8b8ba371cecbb8f76fa8b0b68b1bb7d087528b7d5a99b4f53cccf92
|
|
4
|
+
data.tar.gz: abaaef014089cfc1dfa20e653a5c86f0614b3de3c685fc0d19602fcc8a411edc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
"
|
|
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;
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,
|
|
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
|
data/lib/debug_mcp/version.rb
CHANGED
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.
|
|
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
|