debug-mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.ja.md +20 -4
- data/README.md +20 -4
- data/lib/debug_mcp/notifications_subscriber.rb +162 -38
- data/lib/debug_mcp/rails_helper.rb +68 -0
- data/lib/debug_mcp/server.rb +15 -5
- data/lib/debug_mcp/tools/list_files.rb +2 -10
- data/lib/debug_mcp/tools/rails_info.rb +20 -0
- data/lib/debug_mcp/tools/rails_mail_deliveries.rb +222 -0
- data/lib/debug_mcp/tools/rails_recent_events.rb +187 -0
- data/lib/debug_mcp/version.rb +1 -1
- metadata +23 -80
- data/examples/01_simple_bug.rb +0 -43
- data/examples/02_data_pipeline.rb +0 -93
- data/examples/03_recursion.rb +0 -96
- data/examples/RAILS_SCENARIOS.md +0 -350
- data/examples/SCENARIOS.md +0 -142
- data/examples/rails_test_app/setup.sh +0 -428
- data/examples/rails_test_app/testapp/.dockerignore +0 -10
- data/examples/rails_test_app/testapp/.ruby-version +0 -1
- data/examples/rails_test_app/testapp/Dockerfile +0 -23
- data/examples/rails_test_app/testapp/Gemfile +0 -17
- data/examples/rails_test_app/testapp/README.md +0 -65
- data/examples/rails_test_app/testapp/Rakefile +0 -6
- data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
- data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +0 -1
- data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +0 -4
- data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +0 -38
- data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +0 -11
- data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +0 -100
- data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +0 -82
- data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +0 -25
- data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +0 -44
- data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +0 -2
- data/examples/rails_test_app/testapp/app/models/application_record.rb +0 -3
- data/examples/rails_test_app/testapp/app/models/comment.rb +0 -8
- data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/models/order.rb +0 -56
- data/examples/rails_test_app/testapp/app/models/order_item.rb +0 -16
- data/examples/rails_test_app/testapp/app/models/post.rb +0 -29
- data/examples/rails_test_app/testapp/app/models/user.rb +0 -34
- data/examples/rails_test_app/testapp/app/services/order_report_service.rb +0 -40
- data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +0 -28
- data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +0 -22
- data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +0 -26
- data/examples/rails_test_app/testapp/bin/ci +0 -6
- data/examples/rails_test_app/testapp/bin/dev +0 -2
- data/examples/rails_test_app/testapp/bin/rails +0 -4
- data/examples/rails_test_app/testapp/bin/rake +0 -4
- data/examples/rails_test_app/testapp/bin/setup +0 -35
- data/examples/rails_test_app/testapp/config/application.rb +0 -42
- data/examples/rails_test_app/testapp/config/boot.rb +0 -3
- data/examples/rails_test_app/testapp/config/ci.rb +0 -14
- data/examples/rails_test_app/testapp/config/database.yml +0 -32
- data/examples/rails_test_app/testapp/config/environment.rb +0 -5
- data/examples/rails_test_app/testapp/config/environments/development.rb +0 -54
- data/examples/rails_test_app/testapp/config/environments/production.rb +0 -67
- data/examples/rails_test_app/testapp/config/environments/test.rb +0 -42
- data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +0 -29
- data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +0 -8
- data/examples/rails_test_app/testapp/config/initializers/inflections.rb +0 -16
- data/examples/rails_test_app/testapp/config/locales/en.yml +0 -31
- data/examples/rails_test_app/testapp/config/puma.rb +0 -39
- data/examples/rails_test_app/testapp/config/routes.rb +0 -34
- data/examples/rails_test_app/testapp/config.ru +0 -6
- data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +0 -12
- data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +0 -13
- data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +0 -11
- data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +0 -14
- data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +0 -13
- data/examples/rails_test_app/testapp/db/schema.rb +0 -71
- data/examples/rails_test_app/testapp/db/seeds.rb +0 -85
- data/examples/rails_test_app/testapp/docker-compose.yml +0 -21
- data/examples/rails_test_app/testapp/docker-entrypoint.sh +0 -10
- data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
- data/examples/rails_test_app/testapp/log/.keep +0 -0
- data/examples/rails_test_app/testapp/public/400.html +0 -135
- data/examples/rails_test_app/testapp/public/404.html +0 -135
- data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +0 -135
- data/examples/rails_test_app/testapp/public/422.html +0 -135
- data/examples/rails_test_app/testapp/public/500.html +0 -135
- data/examples/rails_test_app/testapp/public/icon.png +0 -0
- data/examples/rails_test_app/testapp/public/icon.svg +0 -3
- data/examples/rails_test_app/testapp/public/robots.txt +0 -1
- data/examples/rails_test_app/testapp/script/.keep +0 -0
- data/examples/rails_test_app/testapp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/vendor/.keep +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3d8443d036bf6e58f7733e1a34f7cfb12d8d21f8efe922af9f50e98c55ebcc8
|
|
4
|
+
data.tar.gz: e22ddb40fe698ace1b8251f8da4c42dd76c91b1023011e4d8864f1adadd3703a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 57ca99711e4d91e66b1d534ecb1bd55609b39c80e3172e01bd1c3b37be0cdbe3795481db0fb48636dd57a72ae99f7ce87ff7a8f54d77d5622e4f2cbf4e7a9f1e
|
|
7
|
+
data.tar.gz: b95dd34474b6f8b2e330a514a9e06d724139df98d1ed78b23d8f92124dc56ae4a8c5e5e0f86f903d7bda3b9e2d08d479c9a7101cb9d16dff2792065852e94453
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
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
|
+
|
|
50
|
+
## 0.2.1 — 2026-06-17
|
|
51
|
+
|
|
52
|
+
### Bug Fixes
|
|
53
|
+
|
|
54
|
+
- **Fix `list_files` with explicit `session_id`** — `list_files` used an outdated
|
|
55
|
+
`SessionManager#get` call path when a session ID was provided, causing
|
|
56
|
+
`NoMethodError` instead of listing files. It now uses the same
|
|
57
|
+
`SessionManager#client(session_id)` API as other tools.
|
|
58
|
+
|
|
3
59
|
## 0.2.0 — 2026-05-14
|
|
4
60
|
|
|
5
61
|
### Features
|
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
|
|
|
@@ -411,7 +423,11 @@ Agent: get_context()
|
|
|
411
423
|
|
|
412
424
|
debug-mcpはデバッグツールであり、実行中のRubyプロセスへの深いランタイムアクセスを提供します。
|
|
413
425
|
|
|
414
|
-
|
|
426
|
+
**開発環境専用です。** debug-mcpは自分のローカル/開発プロセスへのattachを想定しており、本番環境は対象外です。深いランタイムアクセス(`evaluate_code`によるコード実行を含む)を提供するもので、敵対的な対象に対して安全に作られているわけではありません。
|
|
427
|
+
|
|
428
|
+
**専用ツールにより任意コード実行を最小化。** 変数の確認・ソースコードの閲覧・モデル構造の調査など、ほとんどのデバッグ操作は任意コードを実行しない専用ツールで行われます。ランタイム検査用に`evaluate_code`も利用可能で、危険な操作に対しては組み込みの安全性チェッカーが警告します。なお、このチェッカーやツール選択の誘導はあくまでadvisory(助言)であり、エージェントを促すだけで実行をブロックするものではありません。
|
|
429
|
+
|
|
430
|
+
**信頼できないデータには向けないでください。** エージェントは対象プロセスのランタイムデータ(変数値・DBの行・HTTPレスポンスなど)を読み取り、`evaluate_code`を通じてそれに基づいて動作し得ます。上記の安全策はadvisoryなので、そのデータにprompt injectionのペイロードが含まれているとエージェントが誘導される可能性があります。injectionが差し込まれ得る入力を扱うプロセスに対してdebug-mcpを実行しないでください。
|
|
415
431
|
|
|
416
432
|
**debug gemには認証機能がありません。** デバッグソケットにアクセスできれば、対象プロセスで任意のコードを実行できます。必ずlocalhost(`127.0.0.1`)にバインドするか、Unixソケットを使用してください。具体的な設定例は[Docker節](#docker内のrailsアプリをデバッグ)を参照してください。
|
|
417
433
|
|
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
|
|
208
|
+
### Rails Tools
|
|
209
209
|
|
|
210
|
-
These tools are
|
|
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
|
|
|
@@ -412,7 +424,11 @@ Agent: get_context()
|
|
|
412
424
|
|
|
413
425
|
debug-mcp is a debugging tool that intentionally provides deep runtime access. Here's what you should know:
|
|
414
426
|
|
|
415
|
-
**
|
|
427
|
+
**For development environments only.** debug-mcp is meant for attaching to your own local or development processes — not production. It grants deep runtime access (including code execution via `evaluate_code`) and is not designed to be safe against a hostile target.
|
|
428
|
+
|
|
429
|
+
**Structured tools minimize arbitrary code execution.** Most debugging tasks — viewing variables, reading source code, inspecting model structure — are handled by dedicated tools that don't run arbitrary code. `evaluate_code` is available for runtime inspection, and a built-in safety checker warns about dangerous operations. Note that this checker and the tool-selection guidance are advisory — they nudge the agent but do not block execution.
|
|
430
|
+
|
|
431
|
+
**Don't point it at untrusted data.** The agent reads runtime data from the target process (variable values, DB rows, HTTP responses) and can act on it via `evaluate_code`. Because the safeguards above are advisory, a prompt-injection payload in that data could steer the agent. Don't run debug-mcp against a process handling input where injection could be introduced.
|
|
416
432
|
|
|
417
433
|
**The debug gem has no authentication.** Anyone who can reach the debug socket can execute arbitrary code in the target process. Always bind to localhost (`127.0.0.1`) or use Unix sockets. See the [Docker section](#debug-a-dockerized-rails-app) for configuration examples.
|
|
418
434
|
|
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
safe_read { @buffer.select { |e| e[:request_id] == request_id } }
|
|
48
104
|
end
|
|
49
105
|
|
|
50
106
|
def fetch_since(timestamp)
|
|
51
|
-
|
|
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
|
-
|
|
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 =
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
data/lib/debug_mcp/server.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
when
|
|
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
|
|
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:
|
|
@@ -55,11 +55,7 @@ module DebugMcp
|
|
|
55
55
|
private
|
|
56
56
|
|
|
57
57
|
def get_client(server_context, session_id)
|
|
58
|
-
|
|
59
|
-
server_context[:session_manager].get(session_id).client
|
|
60
|
-
else
|
|
61
|
-
server_context[:session_manager].client
|
|
62
|
-
end
|
|
58
|
+
server_context[:session_manager].client(session_id)
|
|
63
59
|
rescue DebugMcp::Error
|
|
64
60
|
nil
|
|
65
61
|
end
|
|
@@ -76,11 +72,7 @@ module DebugMcp
|
|
|
76
72
|
end
|
|
77
73
|
|
|
78
74
|
def remote_cwd(server_context, session_id)
|
|
79
|
-
client =
|
|
80
|
-
server_context[:session_manager].get(session_id).client
|
|
81
|
-
else
|
|
82
|
-
server_context[:session_manager].client
|
|
83
|
-
end
|
|
75
|
+
client = server_context[:session_manager].client(session_id)
|
|
84
76
|
client.auto_repause!
|
|
85
77
|
result = client.send_command("p Dir.pwd")
|
|
86
78
|
cleaned = result.strip.sub(/\A=> /, "")
|