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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.ja.md +15 -3
  4. data/README.md +15 -3
  5. data/lib/debug_mcp/notifications_subscriber.rb +162 -38
  6. data/lib/debug_mcp/rails_helper.rb +68 -0
  7. data/lib/debug_mcp/server.rb +15 -5
  8. data/lib/debug_mcp/tools/rails_info.rb +20 -0
  9. data/lib/debug_mcp/tools/rails_mail_deliveries.rb +222 -0
  10. data/lib/debug_mcp/tools/rails_recent_events.rb +187 -0
  11. data/lib/debug_mcp/version.rb +1 -1
  12. metadata +23 -80
  13. data/examples/01_simple_bug.rb +0 -43
  14. data/examples/02_data_pipeline.rb +0 -93
  15. data/examples/03_recursion.rb +0 -96
  16. data/examples/RAILS_SCENARIOS.md +0 -350
  17. data/examples/SCENARIOS.md +0 -142
  18. data/examples/rails_test_app/setup.sh +0 -428
  19. data/examples/rails_test_app/testapp/.dockerignore +0 -10
  20. data/examples/rails_test_app/testapp/.ruby-version +0 -1
  21. data/examples/rails_test_app/testapp/Dockerfile +0 -23
  22. data/examples/rails_test_app/testapp/Gemfile +0 -17
  23. data/examples/rails_test_app/testapp/README.md +0 -65
  24. data/examples/rails_test_app/testapp/Rakefile +0 -6
  25. data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
  26. data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +0 -1
  27. data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +0 -4
  28. data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
  29. data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +0 -38
  30. data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +0 -11
  31. data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +0 -100
  32. data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +0 -82
  33. data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +0 -25
  34. data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +0 -44
  35. data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +0 -2
  36. data/examples/rails_test_app/testapp/app/models/application_record.rb +0 -3
  37. data/examples/rails_test_app/testapp/app/models/comment.rb +0 -8
  38. data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
  39. data/examples/rails_test_app/testapp/app/models/order.rb +0 -56
  40. data/examples/rails_test_app/testapp/app/models/order_item.rb +0 -16
  41. data/examples/rails_test_app/testapp/app/models/post.rb +0 -29
  42. data/examples/rails_test_app/testapp/app/models/user.rb +0 -34
  43. data/examples/rails_test_app/testapp/app/services/order_report_service.rb +0 -40
  44. data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +0 -28
  45. data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +0 -22
  46. data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +0 -26
  47. data/examples/rails_test_app/testapp/bin/ci +0 -6
  48. data/examples/rails_test_app/testapp/bin/dev +0 -2
  49. data/examples/rails_test_app/testapp/bin/rails +0 -4
  50. data/examples/rails_test_app/testapp/bin/rake +0 -4
  51. data/examples/rails_test_app/testapp/bin/setup +0 -35
  52. data/examples/rails_test_app/testapp/config/application.rb +0 -42
  53. data/examples/rails_test_app/testapp/config/boot.rb +0 -3
  54. data/examples/rails_test_app/testapp/config/ci.rb +0 -14
  55. data/examples/rails_test_app/testapp/config/database.yml +0 -32
  56. data/examples/rails_test_app/testapp/config/environment.rb +0 -5
  57. data/examples/rails_test_app/testapp/config/environments/development.rb +0 -54
  58. data/examples/rails_test_app/testapp/config/environments/production.rb +0 -67
  59. data/examples/rails_test_app/testapp/config/environments/test.rb +0 -42
  60. data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +0 -29
  61. data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +0 -8
  62. data/examples/rails_test_app/testapp/config/initializers/inflections.rb +0 -16
  63. data/examples/rails_test_app/testapp/config/locales/en.yml +0 -31
  64. data/examples/rails_test_app/testapp/config/puma.rb +0 -39
  65. data/examples/rails_test_app/testapp/config/routes.rb +0 -34
  66. data/examples/rails_test_app/testapp/config.ru +0 -6
  67. data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +0 -12
  68. data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +0 -13
  69. data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +0 -11
  70. data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +0 -14
  71. data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +0 -13
  72. data/examples/rails_test_app/testapp/db/schema.rb +0 -71
  73. data/examples/rails_test_app/testapp/db/seeds.rb +0 -85
  74. data/examples/rails_test_app/testapp/docker-compose.yml +0 -21
  75. data/examples/rails_test_app/testapp/docker-entrypoint.sh +0 -10
  76. data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
  77. data/examples/rails_test_app/testapp/log/.keep +0 -0
  78. data/examples/rails_test_app/testapp/public/400.html +0 -135
  79. data/examples/rails_test_app/testapp/public/404.html +0 -135
  80. data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +0 -135
  81. data/examples/rails_test_app/testapp/public/422.html +0 -135
  82. data/examples/rails_test_app/testapp/public/500.html +0 -135
  83. data/examples/rails_test_app/testapp/public/icon.png +0 -0
  84. data/examples/rails_test_app/testapp/public/icon.svg +0 -3
  85. data/examples/rails_test_app/testapp/public/robots.txt +0 -1
  86. data/examples/rails_test_app/testapp/script/.keep +0 -0
  87. data/examples/rails_test_app/testapp/storage/.keep +0 -0
  88. data/examples/rails_test_app/testapp/tmp/.keep +0 -0
  89. data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
  90. data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
  91. data/examples/rails_test_app/testapp/vendor/.keep +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3deb8c5be59f4db39f02fbf3e4930cf89f3baf3c7389907e313fc1c50fbf9ebf
4
- data.tar.gz: a42a9c9c6b89566810cfc939a550c12fa5729c85ad6f60d1608c3b8174a4cc15
3
+ metadata.gz: d3d8443d036bf6e58f7733e1a34f7cfb12d8d21f8efe922af9f50e98c55ebcc8
4
+ data.tar.gz: e22ddb40fe698ace1b8251f8da4c42dd76c91b1023011e4d8864f1adadd3703a
5
5
  SHA512:
6
- metadata.gz: 47de1a4d322016105ec10929a3bffb06c2b627582b910f58ac4d0455022a640c9ee5907cc06f14b463cb40c4a81de26a4717f25a9cb382da346c67191f2e8b90
7
- data.tar.gz: 214301d83a465f5df7206eed4c0592fe849f62882548979931a7bc8ea8aae689af12c3499c727d57c29fedec7dc719e6fc9ff0c1efb4aef5b04a335b22c9f7ee
6
+ metadata.gz: 57ca99711e4d91e66b1d534ecb1bd55609b39c80e3172e01bd1c3b37be0cdbe3795481db0fb48636dd57a72ae99f7ce87ff7a8f54d77d5622e4f2cbf4e7a9f1e
7
+ data.tar.gz: b95dd34474b6f8b2e330a514a9e06d724139df98d1ed78b23d8f92124dc56ae4a8c5e5e0f86f903d7bda3b9e2d08d479c9a7101cb9d16dff2792065852e94453
data/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-06-18
4
+
5
+ ### Features
6
+
7
+ - **`rails_recent_events` tool** — Read recent Rails internal events (SQL, renders,
8
+ cache, job enqueues, request lifecycle) from the running process without
9
+ `trigger_request`. Forward-only (the first call installs the subscriber) and
10
+ paused-only; every response carries an observability header (`installed_at`,
11
+ `forward_only`, `events_before_install_are_unavailable`, buffer size + dropped
12
+ count, `seq` range) so an empty result is never mistaken for "nothing happened".
13
+ Clock-independent `after_seq` cursor paging.
14
+
15
+ - **`rails_mail_deliveries` tool** — Structure `ActionMailer::Base.deliveries`
16
+ (from/to/cc/bcc/subject/body preview/attachment names). `observable` is true only
17
+ when `delivery_method` is `:test`; otherwise the response states that an empty list
18
+ is not proof no mail was sent. Bodies are truncated to a preview inside the target
19
+ process (transport-safe, PII-safe by default); attachment content is never returned.
20
+
21
+ - **`rails_info` Observability section** — `rails_info` now reports `delivery_method`,
22
+ ActiveJob `queue_adapter`, cache store, and PID, so an agent can tell up front
23
+ whether mail/jobs are observable in this process.
24
+
25
+ ### Bug Fixes
26
+
27
+ - **NotificationsSubscriber lifecycle (machine-verified)** — The injected buffer
28
+ module now separates definition from activation: `.install` is always called and is
29
+ idempotent, so a module left with zero subscriptions (e.g. an install attempt that
30
+ raised in signal trap context, where `ActiveSupport::Notifications.subscribe` takes
31
+ an internal mutex) recovers on the next injection instead of being permanently
32
+ poisoned. `install` refuses early in trap context. The injected module is versioned
33
+ so an older one in a long-running process is replaced. Reads use `Mutex#try_lock`
34
+ with a lockless fallback so a fetch can't deadlock against a debugger-stopped thread.
35
+ Per-event monotonic `seq` with clock-independent `fetch_last` / `fetch_after_seq`.
36
+ SQL bodies and request paths are truncated at save time.
37
+
38
+ - **Notifications event capture over the debug socket** — Results are now
39
+ returned as the evaluated expression's value (base64-encoded JSON) instead of
40
+ via `puts`. End-to-end testing against a live rdbg-attached process showed the
41
+ debug socket does not forward the debuggee's stdout, so the previous
42
+ `puts(x.to_json)` path returned nothing — this also fixes `trigger_request`'s
43
+ `Rails Events` section, which used the same mechanism.
44
+
45
+ ### Documentation
46
+
47
+ - Corrected docs that claimed Rails tools are registered only when a Rails process is
48
+ detected — they are always registered and guard themselves via `require_rails!`.
49
+
3
50
  ## 0.2.1 — 2026-06-17
4
51
 
5
52
  ### Bug Fixes
data/README.ja.md CHANGED
@@ -204,15 +204,27 @@ debug-mcp --session-timeout 3600 # 1時間
204
204
  | `run_script` | rdbg経由でRubyスクリプトを起動して接続 |
205
205
  | `trigger_request` | デバッグ中のRailsアプリにHTTPリクエストを送信 |
206
206
 
207
- ### Railsツール(自動検出)
207
+ ### Railsツール
208
208
 
209
- Railsプロセスを検出すると自動的に登録されます。
209
+ 常時登録されますが、Railsプロセスへの接続が必要で、プレーンなRubyスクリプトに対してはエラーを返します。
210
210
 
211
211
  | ツール | 説明 |
212
212
  |------|------|
213
- | `rails_info` | アプリ名・Rails/Rubyバージョン・環境・ルートパスを表示 |
213
+ | `rails_info` | アプリ名・Rails/Rubyバージョン・環境・ルートパス・DB設定に加え、**Observability** セクション(delivery method・queue adapter・cache store・PID)を表示 |
214
214
  | `rails_routes` | ルーティング一覧(verb, path, controller#action)、コントローラ・パスでフィルタ可能 |
215
215
  | `rails_model` | モデル構造:カラム・アソシエーション・バリデーション・enum・スコープを表示 |
216
+ | `rails_recent_events` | `trigger_request` を介さず、実行中プロセスの直近Railsイベント(SQL/render/cache/job/request)を表示 |
217
+ | `rails_mail_deliveries` | `ActionMailer::Base.deliveries` のメール(from/to/件名/本文プレビュー/添付名)を表示 |
218
+
219
+ ### リクエストの外側での実行時観測
220
+
221
+ 次の3ツールで、AIはログを読まずに副作用を確認できます。
222
+
223
+ - **`rails_info`** は観測の前提条件を報告します。たとえば `delivery_method` が `:test` か(メールを観測できるか)、どの `queue_adapter` を使っているか。AIは「何が見えて何が見えないか」を最初に把握できます。
224
+ - **`rails_recent_events`** は `trigger_request` と同じ `ActiveSupport::Notifications` バッファを、リクエストとは独立に読み出します。**forward-only**(最初の呼び出しでsubscriberをinstallし、それ以降のイベントのみ可視)かつ **paused-only**(対象プロセスがdebuggerプロンプトで停止している必要あり)です。応答には毎回ヘッダ(`installed_at`・`forward_only`・`events_before_install_are_unavailable`・buffer件数/drop件数・`seq` 範囲)が付き、空の結果を「何も起きなかった」と誤解しないようにしています。`after_seq` カーソルで前方ページング可能(時計に非依存)。subscriber注入はプロセス内instrumentationの副作用なので、厳密にはread-onlyではありません。
225
+ - **`rails_mail_deliveries`** は `ActionMailer::Base.deliveries` を構造化します。`delivery_method` が `:test` のときだけ中身が入るため、それ以外では「観測不能」と明示し、空=未送信ではないことを示します。本文はデフォルトでプレビューに切り詰め(PII対策)、添付の中身は返しません。
226
+
227
+ > 未提供: 専用の `rails_jobs`(queueスナップショット)ツール。ActiveJobのenqueueは既に `enqueue.active_job` として `rails_recent_events` / `trigger_request` に現れます。TestAdapterの `enqueued_jobs`/`performed_jobs` スナップショットは今後の課題です。
216
228
 
217
229
  ## Rails イベントキャプチャ
218
230
 
data/README.md CHANGED
@@ -205,15 +205,27 @@ The session manager also detects and cleans up sessions whose target process has
205
205
  | `run_script` | Start a Ruby script under rdbg and connect to it |
206
206
  | `trigger_request` | Send an HTTP request to a Rails app under debug |
207
207
 
208
- ### Rails Tools (auto-detected)
208
+ ### Rails Tools
209
209
 
210
- These tools are automatically registered when a Rails process is detected.
210
+ These tools are always registered, but they require a connected Rails process and return an error when used against a plain Ruby script.
211
211
 
212
212
  | Tool | Description |
213
213
  |------|-------------|
214
- | `rails_info` | Show app name, Rails/Ruby versions, environment, root path |
214
+ | `rails_info` | Show app name, Rails/Ruby versions, environment, root path, DB config, and an **Observability** section (delivery method, queue adapter, cache store, PID) |
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
+ | `rails_recent_events` | Show recent Rails internal events (SQL/render/cache/job/request) from the running process, without `trigger_request` |
218
+ | `rails_mail_deliveries` | Show emails in `ActionMailer::Base.deliveries` (from/to/subject/body preview/attachment names) |
219
+
220
+ ### Runtime observability beyond a single request
221
+
222
+ Three of the Rails tools let an agent confirm side effects without reading logs:
223
+
224
+ - **`rails_info`** reports the observability *preconditions* — e.g. whether `delivery_method` is `:test` (so mail is observable) and which `queue_adapter` is in use — so the agent knows up front what it can and cannot see.
225
+ - **`rails_recent_events`** reads the same `ActiveSupport::Notifications` buffer used by `trigger_request`, but independent of a request. It is **forward-only** (the first call installs the subscriber; only events fired afterwards are visible) and **paused-only** (the process must be stopped at a debugger prompt). Every response includes a header — `installed_at`, `forward_only`, `events_before_install_are_unavailable`, buffer size/dropped count, and the `seq` range — so an empty result is never mistaken for "nothing happened". Page forward with the `after_seq` cursor (clock-independent). Installing the subscriber is an in-process instrumentation side effect, so this tool is not strictly read-only.
226
+ - **`rails_mail_deliveries`** structures `ActionMailer::Base.deliveries`. It is only populated when `delivery_method` is `:test`; otherwise the response says so, so an empty list does not imply no mail was sent. Bodies are truncated to a preview (PII-safe by default) and attachment content is never returned.
227
+
228
+ > Not yet included: a dedicated `rails_jobs` queue-snapshot tool. ActiveJob enqueues already appear in `rails_recent_events` / `trigger_request` via `enqueue.active_job`; a TestAdapter `enqueued_jobs`/`performed_jobs` snapshot is planned as a follow-up.
217
229
 
218
230
  ## Rails event capture
219
231
 
@@ -8,6 +8,18 @@ module DebugMcp
8
8
  module NotificationsSubscriber
9
9
  BUFFER_MAX = 1000
10
10
 
11
+ # Bumped whenever INJECTION_CODE's structure changes so that an older buffer
12
+ # module already injected into a long-running target process is replaced
13
+ # instead of silently kept. The injected module exposes `.version` and the
14
+ # injection guard re-defines the module when the versions differ.
15
+ VERSION = "2"
16
+
17
+ # Save-time caps applied inside the target process, before JSON encoding.
18
+ # These bound transport size (the debug socket is line-oriented — see
19
+ # parse_json_array) and limit how much raw SQL / path text we retain.
20
+ STORE_SQL_MAX = 2000
21
+ STORE_PATH_MAX = 1000
22
+
11
23
  SUBSCRIBED_EVENTS = %w[
12
24
  sql.active_record
13
25
  render_template.action_view
@@ -23,38 +35,108 @@ module DebugMcp
23
35
  process_action.action_controller
24
36
  ].freeze
25
37
 
38
+ # Code injected (via Base64 eval) into the target process. It defines a
39
+ # process-global ::DebugMcpNotificationsBuffer module that subscribes to
40
+ # ActiveSupport::Notifications and buffers recent events.
41
+ #
42
+ # Lifecycle notes (these fix bugs found by machine verification):
43
+ # - Module DEFINITION and subscriber ACTIVATION are separated. The
44
+ # `.install` call lives OUTSIDE the `unless defined?` guard and is always
45
+ # invoked, so a module that was defined but left with zero subscriptions
46
+ # (e.g. a previous install attempt raised in trap context) recovers on the
47
+ # next injection. `.install` itself is idempotent via `@subscriptions.any?`.
48
+ # - Reads never block: `safe_read` uses Mutex#try_lock and falls back to a
49
+ # lockless read. A blocking `synchronize` would deadlock if a debugger-
50
+ # stopped thread is holding the mutex; the process is paused during reads,
51
+ # so no writer is actually running and a lockless read is safe.
52
+ # - A version mismatch re-defines the module (after uninstalling the old one).
26
53
  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
54
+ unless defined?(::DebugMcpNotificationsBuffer) &&
55
+ ::DebugMcpNotificationsBuffer.respond_to?(:version) &&
56
+ ::DebugMcpNotificationsBuffer.version == #{VERSION.inspect}
57
+ if defined?(::DebugMcpNotificationsBuffer) && ::DebugMcpNotificationsBuffer.respond_to?(:uninstall)
58
+ ::DebugMcpNotificationsBuffer.uninstall rescue nil
59
+ end
35
60
 
61
+ module ::DebugMcpNotificationsBuffer
36
62
  class << self
37
63
  attr_reader :buffer, :subscriptions
38
64
 
65
+ def version; #{VERSION.inspect}; end
66
+ def buffer_max; #{BUFFER_MAX}; end
67
+ def subscribed_events; #{SUBSCRIBED_EVENTS.inspect}.freeze; end
68
+
69
+ def init!
70
+ @buffer = []
71
+ @subscriptions = []
72
+ @mutex = Mutex.new
73
+ @seq = 0
74
+ @dropped = 0
75
+ @installed_at = nil
76
+ @buffer_started_at = Time.now.to_f
77
+ end
78
+
39
79
  def push(event)
40
80
  @mutex.synchronize do
81
+ @seq += 1
82
+ event[:seq] = @seq
41
83
  @buffer << event
42
- @buffer.shift while @buffer.size > BUFFER_MAX
84
+ while @buffer.size > buffer_max
85
+ @buffer.shift
86
+ @dropped += 1
87
+ end
88
+ end
89
+ end
90
+
91
+ # Read without ever blocking. If try_lock fails the mutex is held by
92
+ # a debugger-stopped thread; we read locklessly (safe while paused).
93
+ def safe_read
94
+ locked = @mutex.try_lock
95
+ begin
96
+ yield
97
+ ensure
98
+ @mutex.unlock if locked
43
99
  end
44
100
  end
45
101
 
46
102
  def fetch_by_request_id(request_id)
47
- @mutex.synchronize { @buffer.select { |e| e[:request_id] == request_id } }
103
+ safe_read { @buffer.select { |e| e[:request_id] == request_id } }
48
104
  end
49
105
 
50
106
  def fetch_since(timestamp)
51
- @mutex.synchronize { @buffer.select { |e| e[:timestamp] >= timestamp } }
107
+ safe_read { @buffer.select { |e| e[:timestamp] >= timestamp } }
108
+ end
109
+
110
+ def fetch_last(n)
111
+ safe_read { @buffer.last(n) }
112
+ end
113
+
114
+ def fetch_after_seq(cursor)
115
+ safe_read { @buffer.select { |e| e[:seq] > cursor } }
52
116
  end
53
117
 
54
118
  def clear
55
119
  @mutex.synchronize { @buffer.clear }
56
120
  end
57
121
 
122
+ def metadata
123
+ safe_read do
124
+ {
125
+ version: version,
126
+ installed: @subscriptions.any?,
127
+ installed_at: @installed_at,
128
+ buffer_started_at: @buffer_started_at,
129
+ buffer_size: @buffer.size,
130
+ buffer_max: buffer_max,
131
+ dropped_count: @dropped,
132
+ oldest_seq: (@buffer.first && @buffer.first[:seq]),
133
+ newest_seq: (@buffer.last && @buffer.last[:seq]),
134
+ last_seq: @seq,
135
+ subscriptions_count: @subscriptions.size,
136
+ }
137
+ end
138
+ end
139
+
58
140
  def install
59
141
  return if @subscriptions.any?
60
142
 
@@ -74,9 +156,10 @@ module DebugMcp
74
156
  # never raise from subscriber callback
75
157
  end
76
158
 
77
- SUBSCRIBED_EVENTS.each do |event_name|
159
+ subscribed_events.each do |event_name|
78
160
  @subscriptions << ::ActiveSupport::Notifications.subscribe(event_name, &callback)
79
161
  end
162
+ @installed_at = Time.now.to_f
80
163
  end
81
164
 
82
165
  def uninstall
@@ -107,7 +190,7 @@ module DebugMcp
107
190
  case name
108
191
  when "sql.active_record"
109
192
  {
110
- sql: payload[:sql].to_s,
193
+ sql: truncate_text(payload[:sql].to_s, #{STORE_SQL_MAX}),
111
194
  query_name: payload[:name].to_s,
112
195
  cached: payload[:cached] ? true : false,
113
196
  binds: safe_binds(payload[:type_casted_binds] || payload[:binds]),
@@ -137,7 +220,7 @@ module DebugMcp
137
220
  controller: payload[:controller],
138
221
  action: payload[:action],
139
222
  method: payload[:method],
140
- path: payload[:path],
223
+ path: truncate_text(payload[:path].to_s, #{STORE_PATH_MAX}),
141
224
  format: payload[:format].to_s,
142
225
  status: payload[:status],
143
226
  view_runtime: payload[:view_runtime],
@@ -150,6 +233,13 @@ module DebugMcp
150
233
  { error: "payload_sanitize_failed: \#{e.class}" }
151
234
  end
152
235
 
236
+ def truncate_text(str, limit)
237
+ return str if str.length <= limit
238
+ str[0, limit] + "...[truncated \#{str.length - limit} chars]"
239
+ rescue StandardError
240
+ "<untruncatable>"
241
+ end
242
+
153
243
  def safe_binds(binds)
154
244
  return [] unless binds.respond_to?(:each)
155
245
  binds.map { |b| safe_inspect(b, 100) }
@@ -164,16 +254,25 @@ module DebugMcp
164
254
  "<uninspectable>"
165
255
  end
166
256
  end
257
+
258
+ init!
167
259
  end
168
- ::DebugMcpNotificationsBuffer.install
169
260
  end
261
+ ::DebugMcpNotificationsBuffer.install
170
262
  RUBY
171
263
 
172
264
  class << self
173
- # Inject the subscriber into the Rails process. Idempotent.
265
+ # Inject the subscriber into the Rails process and activate it. Idempotent.
174
266
  # Returns true on success, false otherwise.
267
+ #
268
+ # Returns false in signal trap context WITHOUT sending the injection: in
269
+ # trap context ActiveSupport::Notifications.subscribe raises ThreadError
270
+ # (Fanout#subscribe takes an internal mutex), and merely defining the
271
+ # module there would leave it with zero subscriptions. We refuse early so
272
+ # the caller can surface RailsHelper::TRAP_CONTEXT_HINT instead.
175
273
  def install(client)
176
274
  return false unless RailsHelper.rails?(client)
275
+ return false if RailsHelper.trap_context?(client)
177
276
 
178
277
  encoded = Base64.strict_encode64(INJECTION_CODE)
179
278
  cmd = "p begin; require 'base64'; eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8')); " \
@@ -197,40 +296,65 @@ module DebugMcp
197
296
  def fetch_by_request_id(client, request_id)
198
297
  return [] unless request_id
199
298
 
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)
299
+ code = RailsHelper.json_command(
300
+ "defined?(::DebugMcpNotificationsBuffer) ? " \
301
+ "::DebugMcpNotificationsBuffer.fetch_by_request_id(#{request_id.inspect}).to_json : '[]'",
302
+ )
303
+ RailsHelper.decode_json_result(client.send_command(code), [])
204
304
  rescue DebugMcp::Error
205
305
  []
206
306
  end
207
307
 
208
308
  # Fetch events fired at-or-after the given timestamp.
309
+ # Prefer fetch_last / fetch_after_seq: those are clock-independent, whereas
310
+ # this compares against a client-supplied timestamp and is sensitive to
311
+ # clock skew between the MCP host and the target process.
209
312
  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)
313
+ code = RailsHelper.json_command(
314
+ "defined?(::DebugMcpNotificationsBuffer) ? " \
315
+ "::DebugMcpNotificationsBuffer.fetch_since(#{timestamp.to_f}).to_json : '[]'",
316
+ )
317
+ RailsHelper.decode_json_result(client.send_command(code), [])
214
318
  rescue DebugMcp::Error
215
319
  []
216
320
  end
217
321
 
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
322
+ # Fetch the last n buffered events. Clock-independent.
323
+ def fetch_last(client, n)
324
+ code = RailsHelper.json_command(
325
+ "defined?(::DebugMcpNotificationsBuffer) ? " \
326
+ "::DebugMcpNotificationsBuffer.fetch_last(#{n.to_i}).to_json : '[]'",
327
+ )
328
+ RailsHelper.decode_json_result(client.send_command(code), [])
329
+ rescue DebugMcp::Error
330
+ []
331
+ end
332
+
333
+ # Fetch events with seq strictly greater than cursor. Clock-independent
334
+ # cursor pagination — pass the previous response's newest_seq. An optional
335
+ # limit caps the result in the TARGET process (oldest-after-cursor first),
336
+ # so a busy buffer never serializes all 1000 events just to be sliced later.
337
+ def fetch_after_seq(client, cursor, limit = nil)
338
+ selector = "::DebugMcpNotificationsBuffer.fetch_after_seq(#{cursor.to_i})"
339
+ selector += ".first(#{limit.to_i})" if limit
340
+ code = RailsHelper.json_command(
341
+ "defined?(::DebugMcpNotificationsBuffer) ? #{selector}.to_json : '[]'",
342
+ )
343
+ RailsHelper.decode_json_result(client.send_command(code), [])
344
+ rescue DebugMcp::Error
232
345
  []
233
346
  end
347
+
348
+ # Fetch subscriber metadata (version, installed_at, buffer_size,
349
+ # dropped_count, seq cursors, ...). Returns {} if not installed.
350
+ def metadata(client)
351
+ code = RailsHelper.json_command(
352
+ "defined?(::DebugMcpNotificationsBuffer) ? ::DebugMcpNotificationsBuffer.metadata.to_json : '{}'",
353
+ )
354
+ RailsHelper.decode_json_result(client.send_command(code), {})
355
+ rescue DebugMcp::Error
356
+ {}
357
+ end
234
358
  end
235
359
  end
236
360
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "base64"
4
+ require "json"
4
5
  require_relative "source_tagging"
5
6
 
6
7
  module DebugMcp
@@ -143,6 +144,32 @@ module DebugMcp
143
144
  nil
144
145
  end
145
146
 
147
+ # Probe Rails runtime observability settings (trap-safe; uses eval_expr).
148
+ # Returns a hash of human-readable string values, falling back to
149
+ # "(unavailable)" when a probe can't be evaluated (e.g. trap context).
150
+ #
151
+ # These are plain attribute/class reads (delivery_method, queue adapter
152
+ # name, cache store class) that do NOT fire ActiveSupport::Notifications
153
+ # events, so no debug_eval source tagging is needed — consistent with
154
+ # eval_expr's contract. Shared by rails_info and the mail/recent-events
155
+ # tools so they report the same observability preconditions.
156
+ def observability_probe(client)
157
+ {
158
+ delivery_method: probe_value(client,
159
+ "(defined?(ActionMailer::Base) ? ActionMailer::Base.delivery_method : :no_action_mailer)"),
160
+ queue_adapter: probe_value(client,
161
+ "(defined?(ActiveJob::Base) ? " \
162
+ "(ActiveJob::Base.queue_adapter_name rescue ActiveJob::Base.queue_adapter.class.name) : " \
163
+ ":no_active_job)"),
164
+ cache_store: probe_value(client,
165
+ "((defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache) ? Rails.cache.class.name : '(none)')"),
166
+ }
167
+ end
168
+
169
+ def probe_value(client, expr)
170
+ eval_expr(client, expr) || "(unavailable)"
171
+ end
172
+
146
173
  # Get the path to the Rails log file (trap-safe).
147
174
  # Returns the absolute path string or nil if not determinable.
148
175
  def log_file_path(client)
@@ -155,6 +182,47 @@ module DebugMcp
155
182
  nil
156
183
  end
157
184
 
185
+ # Wrap a target-side expression that evaluates to a JSON STRING so its value
186
+ # comes back as a base64 blob on the debug gem's `=> <result>` line.
187
+ #
188
+ # Why base64: send_command only returns the evaluated expression's inspected
189
+ # value; the debuggee's own stdout (anything printed with puts/p) is NOT
190
+ # forwarded over the debug socket. So we cannot rely on `puts(x.to_json)`
191
+ # emitting a parseable line — its output goes to the target's stdout and
192
+ # send_command just sees `=> nil`. Returning the JSON directly would work but
193
+ # then it is wrapped in Ruby string-inspect escaping (\" , \\ , \n) that is
194
+ # fragile to undo. Base64's alphabet contains none of those, so it round-trips
195
+ # through inspect/quoting untouched.
196
+ def json_command(json_string_expr)
197
+ "[(#{json_string_expr})].pack(\"m0\")"
198
+ end
199
+
200
+ # Decode the result of a json_command from send_command output. The debug gem
201
+ # echoes the evaluated value as a quoted string, e.g. `"<base64>"` (sometimes
202
+ # with a `=> ` prefix, and — for long values — WRAPPED across several lines at
203
+ # the debugger's width, which read_until_input joins with newlines).
204
+ #
205
+ # The value is a base64 blob, whose alphabet contains no `"`, so we take
206
+ # everything between the first and last double-quote and strip whitespace
207
+ # (Base64.decode64 also ignores embedded newlines). Returns `default` on any
208
+ # failure (not installed, parse error, timeout-truncated output, ...).
209
+ def decode_json_result(output, default)
210
+ return default unless output
211
+
212
+ first = output.index('"')
213
+ last = output.rindex('"')
214
+ return default unless first && last && last > first
215
+
216
+ b64 = output[(first + 1)...last].gsub(/\s/, "")
217
+ return default if b64.empty?
218
+
219
+ # strict_decode64 (vs decode64) rejects a truncated/corrupt blob instead of
220
+ # silently returning partial bytes, so we fall back to `default` cleanly.
221
+ JSON.parse(Base64.strict_decode64(b64), symbolize_names: true)
222
+ rescue StandardError
223
+ default
224
+ end
225
+
158
226
  TRAP_CONTEXT_HINT = "Note: The process may be in signal trap context (common with Puma). " \
159
227
  "Set a breakpoint and use trigger_request to escape trap context first."
160
228
  end
@@ -23,6 +23,8 @@ require_relative "tools/disconnect"
23
23
  require_relative "tools/rails_info"
24
24
  require_relative "tools/rails_routes"
25
25
  require_relative "tools/rails_model"
26
+ require_relative "tools/rails_recent_events"
27
+ require_relative "tools/rails_mail_deliveries"
26
28
 
27
29
  module DebugMcp
28
30
  class Server
@@ -53,11 +55,17 @@ module DebugMcp
53
55
  Tools::TriggerRequest,
54
56
  ].freeze
55
57
 
56
- # Rails tools: dynamically added when a Rails process is detected
58
+ # Rails tools: always registered (see TOOLS below). They require a connected
59
+ # Rails process and guard themselves via RailsHelper.require_rails!, returning
60
+ # an error on plain Ruby targets. register_rails_tools exists for hosts that
61
+ # want to add them to a pre-built server, but the default server registers
62
+ # them up front.
57
63
  RAILS_TOOLS = [
58
64
  Tools::RailsInfo,
59
65
  Tools::RailsRoutes,
60
66
  Tools::RailsModel,
67
+ Tools::RailsRecentEvents,
68
+ Tools::RailsMailDeliveries,
61
69
  ].freeze
62
70
 
63
71
  # All tools (used in tests and for reference)
@@ -116,9 +124,11 @@ module DebugMcp
116
124
  trigger_request handles resuming the process automatically.
117
125
 
118
126
  Rails debugging:
119
- When you connect to a Rails process, additional Rails-specific tools become available \
120
- automatically (rails_info, rails_routes, rails_model). These tools are NOT shown \
121
- when debugging plain Ruby scripts.
127
+ Rails-specific tools (rails_info, rails_routes, rails_model, rails_recent_events) are \
128
+ always available, but they require a connected Rails process and will return an error \
129
+ when used against a plain Ruby script. rails_recent_events shows recent internal events \
130
+ (SQL, renders, cache, job enqueues) captured from the running process; it is forward-only \
131
+ (only events after its first call are visible).
122
132
 
123
133
  Rails debugging workflow:
124
134
  1. Start the Rails server with debugging: RUBY_DEBUG_OPEN=true bin/rails server
@@ -134,7 +144,7 @@ module DebugMcp
134
144
  7. To debug another request, set new breakpoints and call trigger_request again
135
145
  8. When done debugging, use 'disconnect' to detach and resume the server
136
146
 
137
- Note: rails_info, rails_routes, and rails_model may not work in trap context. \
147
+ Note: rails_info, rails_routes, rails_model, and rails_recent_events may not work in trap context. \
138
148
  Use them after hitting a breakpoint via trigger_request.
139
149
 
140
150
  Docker / containerized processes:
@@ -50,6 +50,9 @@ module DebugMcp
50
50
  # Route summary
51
51
  parts << build_routes_section(client)
52
52
 
53
+ # Runtime observability (delivery method, queue adapter, cache store)
54
+ parts << build_observability_section(client)
55
+
53
56
  text = parts.compact.join("\n\n")
54
57
 
55
58
  if text.include?("(unavailable)")
@@ -112,6 +115,23 @@ module DebugMcp
112
115
  RailsHelper.run_base64_script(client, code)
113
116
  end
114
117
 
118
+ # Runtime observability: which side effects can be observed in this
119
+ # process. Trap-safe (uses eval_expr). Values show "(unavailable)" when
120
+ # they can't be read, which also triggers the trap-context hint.
121
+ def build_observability_section(client)
122
+ probe = RailsHelper.observability_probe(client)
123
+ pid = eval_expr(client, "Process.pid")
124
+
125
+ lines = ["=== Observability ==="]
126
+ lines << "ActionMailer delivery_method: #{probe[:delivery_method]}"
127
+ lines << "ActiveJob queue_adapter: #{probe[:queue_adapter]}"
128
+ lines << "Cache store: #{probe[:cache_store]}"
129
+ lines << "PID: #{pid}" if pid
130
+ lines << "Note: sent mail is observable via 'rails_mail_deliveries' only when " \
131
+ "delivery_method is :test; recent Rails events via 'rails_recent_events'."
132
+ lines.join("\n")
133
+ end
134
+
115
135
  def eval_expr(client, expr)
116
136
  RailsHelper.eval_expr(client, expr)
117
137
  end