debug-mcp 0.2.1 → 0.4.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 +68 -0
  3. data/README.ja.md +21 -3
  4. data/README.md +21 -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 +26 -20
  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 +39 -82
  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: 9e02b5e90614a86cdefb3a1829405c60c3c11d4bb03dde266c23769fc264e2ac
4
+ data.tar.gz: 6a61f417f4720077d4cede1874719da07865c9b7a4b370e3726aecd9a74596ba
5
5
  SHA512:
6
- metadata.gz: 47de1a4d322016105ec10929a3bffb06c2b627582b910f58ac4d0455022a640c9ee5907cc06f14b463cb40c4a81de26a4717f25a9cb382da346c67191f2e8b90
7
- data.tar.gz: 214301d83a465f5df7206eed4c0592fe849f62882548979931a7bc8ea8aae689af12c3499c727d57c29fedec7dc719e6fc9ff0c1efb4aef5b04a335b22c9f7ee
6
+ metadata.gz: ec411a062c8c2cee35f569c9e73bff9450fdfc99f001c75334b3798019bb8b4d32487a83223f81592af05af1fc9466fe3c4390581f5ff3f80bfd290868bd3b0f
7
+ data.tar.gz: 7bfe97bf4102f970463dfffd183d02d1d80b3c400170434b3472c68ac57526ce52f66a0a6b9081116e3985ec5a8629b35908d3c409d51222f4403c75ce7aeac8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-10-18
4
+
5
+ ### Dependencies
6
+
7
+ - **Upgrade the `mcp` gem from `~> 0.7` to `~> 1.8`.** The JSON Schema validator
8
+ dependency changes from `json-schema` to `json_schemer`; debug-mcp does not use
9
+ either directly.
10
+ - **The HTTP transport now needs the `rack` gem installed separately.** `mcp` 1.x no
11
+ longer depends on `rack`, but its `StreamableHTTPTransport` still requires it.
12
+ debug-mcp keeps `rack` optional so STDIO users (the default) don't install it;
13
+ `--transport http` without `rack` exits with an install hint
14
+ (`gem install rack`).
15
+
16
+ ### Bug Fixes
17
+
18
+ - **Fix the HTTP transport under `mcp` 1.x** — The WEBrick bridge now passes a
19
+ `Rack::Request` (with `CONTENT_LENGTH` set) instead of a minimal hand-written
20
+ adapter, which lacked `content_length` and made every request return
21
+ `Internal server error`. `stringio` is now required explicitly instead of relying
22
+ on another gem to load it.
23
+
24
+ ## 0.3.0 — 2026-06-18
25
+
26
+ ### Features
27
+
28
+ - **`rails_recent_events` tool** — Read recent Rails internal events (SQL, renders,
29
+ cache, job enqueues, request lifecycle) from the running process without
30
+ `trigger_request`. Forward-only (the first call installs the subscriber) and
31
+ paused-only; every response carries an observability header (`installed_at`,
32
+ `forward_only`, `events_before_install_are_unavailable`, buffer size + dropped
33
+ count, `seq` range) so an empty result is never mistaken for "nothing happened".
34
+ Clock-independent `after_seq` cursor paging.
35
+
36
+ - **`rails_mail_deliveries` tool** — Structure `ActionMailer::Base.deliveries`
37
+ (from/to/cc/bcc/subject/body preview/attachment names). `observable` is true only
38
+ when `delivery_method` is `:test`; otherwise the response states that an empty list
39
+ is not proof no mail was sent. Bodies are truncated to a preview inside the target
40
+ process (transport-safe, PII-safe by default); attachment content is never returned.
41
+
42
+ - **`rails_info` Observability section** — `rails_info` now reports `delivery_method`,
43
+ ActiveJob `queue_adapter`, cache store, and PID, so an agent can tell up front
44
+ whether mail/jobs are observable in this process.
45
+
46
+ ### Bug Fixes
47
+
48
+ - **NotificationsSubscriber lifecycle (machine-verified)** — The injected buffer
49
+ module now separates definition from activation: `.install` is always called and is
50
+ idempotent, so a module left with zero subscriptions (e.g. an install attempt that
51
+ raised in signal trap context, where `ActiveSupport::Notifications.subscribe` takes
52
+ an internal mutex) recovers on the next injection instead of being permanently
53
+ poisoned. `install` refuses early in trap context. The injected module is versioned
54
+ so an older one in a long-running process is replaced. Reads use `Mutex#try_lock`
55
+ with a lockless fallback so a fetch can't deadlock against a debugger-stopped thread.
56
+ Per-event monotonic `seq` with clock-independent `fetch_last` / `fetch_after_seq`.
57
+ SQL bodies and request paths are truncated at save time.
58
+
59
+ - **Notifications event capture over the debug socket** — Results are now
60
+ returned as the evaluated expression's value (base64-encoded JSON) instead of
61
+ via `puts`. End-to-end testing against a live rdbg-attached process showed the
62
+ debug socket does not forward the debuggee's stdout, so the previous
63
+ `puts(x.to_json)` path returned nothing — this also fixes `trigger_request`'s
64
+ `Rails Events` section, which used the same mechanism.
65
+
66
+ ### Documentation
67
+
68
+ - Corrected docs that claimed Rails tools are registered only when a Rails process is
69
+ detected — they are always registered and guard themselves via `require_rails!`.
70
+
3
71
  ## 0.2.1 — 2026-06-17
4
72
 
5
73
  ### Bug Fixes
data/README.ja.md CHANGED
@@ -147,6 +147,12 @@ debug-mcp
147
147
 
148
148
  ブラウザベースのクライアントやHTTP対応MCPクライアント向け。
149
149
 
150
+ HTTPトランスポートには `rack` gem が必要です。STDIOでは使わないため、debug-mcp と一緒にはインストールされません:
151
+
152
+ ```bash
153
+ gem install rack
154
+ ```
155
+
150
156
  ```bash
151
157
  debug-mcp --transport http --port 8080
152
158
  ```
@@ -204,15 +210,27 @@ debug-mcp --session-timeout 3600 # 1時間
204
210
  | `run_script` | rdbg経由でRubyスクリプトを起動して接続 |
205
211
  | `trigger_request` | デバッグ中のRailsアプリにHTTPリクエストを送信 |
206
212
 
207
- ### Railsツール(自動検出)
213
+ ### Railsツール
208
214
 
209
- Railsプロセスを検出すると自動的に登録されます。
215
+ 常時登録されますが、Railsプロセスへの接続が必要で、プレーンなRubyスクリプトに対してはエラーを返します。
210
216
 
211
217
  | ツール | 説明 |
212
218
  |------|------|
213
- | `rails_info` | アプリ名・Rails/Rubyバージョン・環境・ルートパスを表示 |
219
+ | `rails_info` | アプリ名・Rails/Rubyバージョン・環境・ルートパス・DB設定に加え、**Observability** セクション(delivery method・queue adapter・cache store・PID)を表示 |
214
220
  | `rails_routes` | ルーティング一覧(verb, path, controller#action)、コントローラ・パスでフィルタ可能 |
215
221
  | `rails_model` | モデル構造:カラム・アソシエーション・バリデーション・enum・スコープを表示 |
222
+ | `rails_recent_events` | `trigger_request` を介さず、実行中プロセスの直近Railsイベント(SQL/render/cache/job/request)を表示 |
223
+ | `rails_mail_deliveries` | `ActionMailer::Base.deliveries` のメール(from/to/件名/本文プレビュー/添付名)を表示 |
224
+
225
+ ### リクエストの外側での実行時観測
226
+
227
+ 次の3ツールで、AIはログを読まずに副作用を確認できます。
228
+
229
+ - **`rails_info`** は観測の前提条件を報告します。たとえば `delivery_method` が `:test` か(メールを観測できるか)、どの `queue_adapter` を使っているか。AIは「何が見えて何が見えないか」を最初に把握できます。
230
+ - **`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ではありません。
231
+ - **`rails_mail_deliveries`** は `ActionMailer::Base.deliveries` を構造化します。`delivery_method` が `:test` のときだけ中身が入るため、それ以外では「観測不能」と明示し、空=未送信ではないことを示します。本文はデフォルトでプレビューに切り詰め(PII対策)、添付の中身は返しません。
232
+
233
+ > 未提供: 専用の `rails_jobs`(queueスナップショット)ツール。ActiveJobのenqueueは既に `enqueue.active_job` として `rails_recent_events` / `trigger_request` に現れます。TestAdapterの `enqueued_jobs`/`performed_jobs` スナップショットは今後の課題です。
216
234
 
217
235
  ## Rails イベントキャプチャ
218
236
 
data/README.md CHANGED
@@ -148,6 +148,12 @@ debug-mcp
148
148
 
149
149
  For browser-based clients or other HTTP-compatible MCP clients.
150
150
 
151
+ The HTTP transport requires the `rack` gem, which is not installed with debug-mcp (STDIO users don't need it):
152
+
153
+ ```bash
154
+ gem install rack
155
+ ```
156
+
151
157
  ```bash
152
158
  debug-mcp --transport http --port 8080
153
159
  ```
@@ -205,15 +211,27 @@ The session manager also detects and cleans up sessions whose target process has
205
211
  | `run_script` | Start a Ruby script under rdbg and connect to it |
206
212
  | `trigger_request` | Send an HTTP request to a Rails app under debug |
207
213
 
208
- ### Rails Tools (auto-detected)
214
+ ### Rails Tools
209
215
 
210
- These tools are automatically registered when a Rails process is detected.
216
+ These tools are always registered, but they require a connected Rails process and return an error when used against a plain Ruby script.
211
217
 
212
218
  | Tool | Description |
213
219
  |------|-------------|
214
- | `rails_info` | Show app name, Rails/Ruby versions, environment, root path |
220
+ | `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
221
  | `rails_routes` | Show routes (verb, path, controller#action), filterable by controller or path |
216
222
  | `rails_model` | Show model structure: columns, associations, validations, enums, scopes |
223
+ | `rails_recent_events` | Show recent Rails internal events (SQL/render/cache/job/request) from the running process, without `trigger_request` |
224
+ | `rails_mail_deliveries` | Show emails in `ActionMailer::Base.deliveries` (from/to/subject/body preview/attachment names) |
225
+
226
+ ### Runtime observability beyond a single request
227
+
228
+ Three of the Rails tools let an agent confirm side effects without reading logs:
229
+
230
+ - **`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.
231
+ - **`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.
232
+ - **`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.
233
+
234
+ > 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
235
 
218
236
  ## Rails event capture
219
237
 
@@ -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:
@@ -253,6 +263,13 @@ module DebugMcp
253
263
  end
254
264
 
255
265
  def start_http(server)
266
+ begin
267
+ require "rack"
268
+ rescue LoadError
269
+ abort "debug-mcp: the HTTP transport requires the 'rack' gem. " \
270
+ "Install it with `gem install rack` (or add `gem \"rack\"` to your Gemfile)."
271
+ end
272
+ require "stringio"
256
273
  require "webrick"
257
274
 
258
275
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
@@ -266,7 +283,7 @@ module DebugMcp
266
283
 
267
284
  webrick.mount_proc("/mcp") do |req, res|
268
285
  env = build_rack_env(req)
269
- rack_request = RackRequestAdapter.new(env)
286
+ rack_request = Rack::Request.new(env)
270
287
  status, headers, body = transport.handle_request(rack_request)
271
288
 
272
289
  res.status = status
@@ -302,27 +319,16 @@ module DebugMcp
302
319
  transport&.close
303
320
  end
304
321
 
305
- # Minimal Rack::Request-compatible adapter for WEBrick
306
- class RackRequestAdapter
307
- attr_reader :env
308
-
309
- def initialize(env)
310
- @env = env
311
- end
312
-
313
- def body
314
- @env["rack.input"]
315
- end
316
- end
317
-
318
322
  def build_rack_env(req)
323
+ body = req.body || ""
319
324
  env = {
320
325
  "REQUEST_METHOD" => req.request_method,
321
326
  "PATH_INFO" => req.path,
322
327
  "QUERY_STRING" => req.query_string || "",
323
328
  "SERVER_NAME" => @http_host,
324
329
  "SERVER_PORT" => @http_port.to_s,
325
- "rack.input" => StringIO.new(req.body || ""),
330
+ "CONTENT_LENGTH" => body.bytesize.to_s,
331
+ "rack.input" => StringIO.new(body),
326
332
  }
327
333
 
328
334
  # Map HTTP headers to Rack convention