debug-mcp 0.1.2

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 (122) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +83 -0
  4. data/LICENSE +21 -0
  5. data/README.ja.md +383 -0
  6. data/README.md +384 -0
  7. data/examples/01_simple_bug.rb +43 -0
  8. data/examples/02_data_pipeline.rb +93 -0
  9. data/examples/03_recursion.rb +96 -0
  10. data/examples/RAILS_SCENARIOS.md +350 -0
  11. data/examples/SCENARIOS.md +142 -0
  12. data/examples/rails_test_app/setup.sh +428 -0
  13. data/examples/rails_test_app/testapp/.dockerignore +10 -0
  14. data/examples/rails_test_app/testapp/.ruby-version +1 -0
  15. data/examples/rails_test_app/testapp/Dockerfile +23 -0
  16. data/examples/rails_test_app/testapp/Gemfile +17 -0
  17. data/examples/rails_test_app/testapp/README.md +65 -0
  18. data/examples/rails_test_app/testapp/Rakefile +6 -0
  19. data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
  20. data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +1 -0
  21. data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +4 -0
  22. data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
  23. data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +38 -0
  24. data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +11 -0
  25. data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +100 -0
  26. data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +82 -0
  27. data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +25 -0
  28. data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +44 -0
  29. data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +2 -0
  30. data/examples/rails_test_app/testapp/app/models/application_record.rb +3 -0
  31. data/examples/rails_test_app/testapp/app/models/comment.rb +8 -0
  32. data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
  33. data/examples/rails_test_app/testapp/app/models/order.rb +56 -0
  34. data/examples/rails_test_app/testapp/app/models/order_item.rb +16 -0
  35. data/examples/rails_test_app/testapp/app/models/post.rb +29 -0
  36. data/examples/rails_test_app/testapp/app/models/user.rb +34 -0
  37. data/examples/rails_test_app/testapp/app/services/order_report_service.rb +40 -0
  38. data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +28 -0
  39. data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +22 -0
  40. data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +26 -0
  41. data/examples/rails_test_app/testapp/bin/ci +6 -0
  42. data/examples/rails_test_app/testapp/bin/dev +2 -0
  43. data/examples/rails_test_app/testapp/bin/rails +4 -0
  44. data/examples/rails_test_app/testapp/bin/rake +4 -0
  45. data/examples/rails_test_app/testapp/bin/setup +35 -0
  46. data/examples/rails_test_app/testapp/config/application.rb +42 -0
  47. data/examples/rails_test_app/testapp/config/boot.rb +3 -0
  48. data/examples/rails_test_app/testapp/config/ci.rb +14 -0
  49. data/examples/rails_test_app/testapp/config/database.yml +32 -0
  50. data/examples/rails_test_app/testapp/config/environment.rb +5 -0
  51. data/examples/rails_test_app/testapp/config/environments/development.rb +54 -0
  52. data/examples/rails_test_app/testapp/config/environments/production.rb +67 -0
  53. data/examples/rails_test_app/testapp/config/environments/test.rb +42 -0
  54. data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +29 -0
  55. data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +8 -0
  56. data/examples/rails_test_app/testapp/config/initializers/inflections.rb +16 -0
  57. data/examples/rails_test_app/testapp/config/locales/en.yml +31 -0
  58. data/examples/rails_test_app/testapp/config/puma.rb +39 -0
  59. data/examples/rails_test_app/testapp/config/routes.rb +34 -0
  60. data/examples/rails_test_app/testapp/config.ru +6 -0
  61. data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +12 -0
  62. data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +13 -0
  63. data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +11 -0
  64. data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +14 -0
  65. data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +13 -0
  66. data/examples/rails_test_app/testapp/db/schema.rb +71 -0
  67. data/examples/rails_test_app/testapp/db/seeds.rb +85 -0
  68. data/examples/rails_test_app/testapp/docker-compose.yml +21 -0
  69. data/examples/rails_test_app/testapp/docker-entrypoint.sh +10 -0
  70. data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
  71. data/examples/rails_test_app/testapp/log/.keep +0 -0
  72. data/examples/rails_test_app/testapp/public/400.html +135 -0
  73. data/examples/rails_test_app/testapp/public/404.html +135 -0
  74. data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +135 -0
  75. data/examples/rails_test_app/testapp/public/422.html +135 -0
  76. data/examples/rails_test_app/testapp/public/500.html +135 -0
  77. data/examples/rails_test_app/testapp/public/icon.png +0 -0
  78. data/examples/rails_test_app/testapp/public/icon.svg +3 -0
  79. data/examples/rails_test_app/testapp/public/robots.txt +1 -0
  80. data/examples/rails_test_app/testapp/script/.keep +0 -0
  81. data/examples/rails_test_app/testapp/storage/.keep +0 -0
  82. data/examples/rails_test_app/testapp/tmp/.keep +0 -0
  83. data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
  84. data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
  85. data/examples/rails_test_app/testapp/vendor/.keep +0 -0
  86. data/exe/debug-mcp +39 -0
  87. data/exe/debug-rails +127 -0
  88. data/lib/debug_mcp/client_cleanup.rb +102 -0
  89. data/lib/debug_mcp/code_safety_analyzer.rb +124 -0
  90. data/lib/debug_mcp/debug_client.rb +1143 -0
  91. data/lib/debug_mcp/exit_message_builder.rb +112 -0
  92. data/lib/debug_mcp/pending_http_helper.rb +25 -0
  93. data/lib/debug_mcp/rails_helper.rb +155 -0
  94. data/lib/debug_mcp/server.rb +364 -0
  95. data/lib/debug_mcp/session_manager.rb +436 -0
  96. data/lib/debug_mcp/stop_event_annotator.rb +152 -0
  97. data/lib/debug_mcp/tcp_session_discovery.rb +226 -0
  98. data/lib/debug_mcp/tools/connect.rb +669 -0
  99. data/lib/debug_mcp/tools/continue_execution.rb +161 -0
  100. data/lib/debug_mcp/tools/disconnect.rb +169 -0
  101. data/lib/debug_mcp/tools/evaluate_code.rb +354 -0
  102. data/lib/debug_mcp/tools/finish.rb +84 -0
  103. data/lib/debug_mcp/tools/get_context.rb +217 -0
  104. data/lib/debug_mcp/tools/get_source.rb +193 -0
  105. data/lib/debug_mcp/tools/inspect_object.rb +107 -0
  106. data/lib/debug_mcp/tools/list_debug_sessions.rb +60 -0
  107. data/lib/debug_mcp/tools/list_files.rb +189 -0
  108. data/lib/debug_mcp/tools/list_paused_sessions.rb +108 -0
  109. data/lib/debug_mcp/tools/next.rb +70 -0
  110. data/lib/debug_mcp/tools/rails_info.rb +200 -0
  111. data/lib/debug_mcp/tools/rails_model.rb +362 -0
  112. data/lib/debug_mcp/tools/rails_routes.rb +186 -0
  113. data/lib/debug_mcp/tools/read_file.rb +214 -0
  114. data/lib/debug_mcp/tools/remove_breakpoint.rb +173 -0
  115. data/lib/debug_mcp/tools/run_debug_command.rb +55 -0
  116. data/lib/debug_mcp/tools/run_script.rb +293 -0
  117. data/lib/debug_mcp/tools/set_breakpoint.rb +206 -0
  118. data/lib/debug_mcp/tools/step.rb +67 -0
  119. data/lib/debug_mcp/tools/trigger_request.rb +515 -0
  120. data/lib/debug_mcp/version.rb +5 -0
  121. data/lib/debug_mcp.rb +40 -0
  122. metadata +251 -0
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module DebugMcp
6
+ module Tools
7
+ class Finish < MCP::Tool
8
+ description "[Control] Run until the current method or block returns, then pause. " \
9
+ "After finish, execution stops at the CALLER's frame (the line that invoked the method). " \
10
+ "The line shown with => has already been executed. " \
11
+ "This exits the current block/method entirely — for iterators like each/map, " \
12
+ "this skips ALL remaining iterations. " \
13
+ "To skip to just the NEXT ITERATION instead, use set_breakpoint with one_shot: true " \
14
+ "on the first line of the block body, then continue_execution."
15
+
16
+ annotations(
17
+ title: "Finish Method/Block",
18
+ read_only_hint: false,
19
+ destructive_hint: false,
20
+ open_world_hint: false,
21
+ )
22
+
23
+ input_schema(
24
+ properties: {
25
+ session_id: {
26
+ type: "string",
27
+ description: "Debug session ID (uses default session if omitted)",
28
+ },
29
+ },
30
+ )
31
+
32
+ class << self
33
+ def call(session_id: nil, server_context:)
34
+ client = server_context[:session_manager].client(session_id)
35
+
36
+ output = client.send_command("finish", timeout: DebugClient::CONTINUE_TIMEOUT)
37
+
38
+ if output.strip.empty? && client.process_finished?
39
+ text = DebugMcp::ExitMessageBuilder.build_exit_message(
40
+ "Program exited during finish.", output, client,
41
+ )
42
+ return MCP::Tool::Response.new([{ type: "text", text: text }])
43
+ end
44
+
45
+ client.cleanup_one_shot_breakpoints(output)
46
+ output = DebugMcp::StopEventAnnotator.annotate_breakpoint_hit(output)
47
+ output = DebugMcp::StopEventAnnotator.enrich_stop_context(output, client)
48
+
49
+ location = parse_stop_location(output)
50
+ header = if location
51
+ "Method/block returned. Now at: #{location}"
52
+ else
53
+ "Method/block returned (stopped at caller's frame)."
54
+ end
55
+ MCP::Tool::Response.new([{ type: "text", text: "#{header}\n\n#{output}" }])
56
+ rescue DebugMcp::SessionError => e
57
+ text = if e.message.include?("session ended") || e.message.include?("finished execution")
58
+ DebugMcp::ExitMessageBuilder.build_exit_message("Program exited during finish.", e.final_output, client)
59
+ else
60
+ "Error: #{e.message}"
61
+ end
62
+ MCP::Tool::Response.new([{ type: "text", text: text }])
63
+ rescue DebugMcp::ConnectionError => e
64
+ text = if e.message.include?("Connection lost") || e.message.include?("connection closed")
65
+ DebugMcp::ExitMessageBuilder.build_exit_message("Program exited during finish.", e.final_output, client)
66
+ else
67
+ "Error: #{e.message}"
68
+ end
69
+ MCP::Tool::Response.new([{ type: "text", text: text }])
70
+ rescue DebugMcp::Error => e
71
+ MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
72
+ end
73
+
74
+ private
75
+
76
+ def parse_stop_location(output)
77
+ # Debug gem output: "=>#0 Class#method at /path/to/file.rb:10"
78
+ match = output&.match(/=>#\d+\s+.+\s+at\s+(.+:\d+)/)
79
+ match ? match[1] : nil
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "../pending_http_helper"
5
+
6
+ module DebugMcp
7
+ module Tools
8
+ class GetContext < MCP::Tool
9
+ description "[Investigation] Get the full execution context of the paused Ruby process: " \
10
+ "current source location, local variables, instance variables, call stack, " \
11
+ "and breakpoints. " \
12
+ "Best used: (1) after connecting/run_script to understand the initial state, " \
13
+ "(2) after continue_execution hits a breakpoint to see variable values, " \
14
+ "(3) when you need to check what breakpoints are set. " \
15
+ "Not needed after every next/step — those already include source listing in their output. " \
16
+ "Note: Variable values may be truncated. " \
17
+ "Use 'evaluate_code' or 'inspect_object' for full details on specific variables."
18
+
19
+ annotations(
20
+ title: "Get Execution Context",
21
+ read_only_hint: true,
22
+ destructive_hint: false,
23
+ open_world_hint: false,
24
+ )
25
+
26
+ input_schema(
27
+ properties: {
28
+ session_id: {
29
+ type: "string",
30
+ description: "Debug session ID (uses default session if omitted)",
31
+ },
32
+ },
33
+ )
34
+
35
+ # Pattern for detecting truncated values in debug gem output.
36
+ # Matches lines where the value portion ends with "..." possibly followed
37
+ # by closing delimiters like ], }, ", or >.
38
+ TRUNCATION_PATTERN = /\.\.\.[\]}"'>)]*\s*\z/
39
+
40
+ # Pattern for detecting framework/gem frames in call stack.
41
+ FRAMEWORK_PATH_PATTERN = %r{/gems/|/\.rbenv/|/\.bundle/|/vendor/bundle/|\[C\]|/ruby/\d}
42
+
43
+ class << self
44
+ def call(session_id: nil, server_context:)
45
+ client = server_context[:session_manager].client(session_id)
46
+ client.auto_repause!
47
+
48
+ parts = []
49
+ total_truncated = 0
50
+
51
+ # Show trap context warning as the first section.
52
+ # Use cached value if available to avoid an extra round-trip.
53
+ in_trap = if client.respond_to?(:trap_context) && !client.trap_context.nil?
54
+ client.trap_context
55
+ elsif client.respond_to?(:in_trap_context?)
56
+ client.in_trap_context?
57
+ end
58
+ if in_trap
59
+ parts << "=== Context: Signal Trap ===\n" \
60
+ "Restricted: DB queries, require, autoloading, method breakpoints\n" \
61
+ "Available: evaluate_code (simple expressions), set_breakpoint (file:line), rails_routes\n" \
62
+ "To escape: set_breakpoint(file, line) + trigger_request"
63
+ end
64
+
65
+ # Collect each section independently so partial results are still useful
66
+ variable_commands = %w[info\ locals info\ ivars]
67
+ sections = [
68
+ ["Current Location", "list"],
69
+ ["Local Variables", "info locals"],
70
+ ["Instance Variables", "info ivars"],
71
+ ["Call Stack", "bt"],
72
+ ["Breakpoints", "info breakpoints"],
73
+ ]
74
+
75
+ bt_raw = nil
76
+ sections.each do |title, command|
77
+ output = client.send_command(command)
78
+
79
+ # For variable sections, detect and annotate truncated values
80
+ if variable_commands.include?(command)
81
+ output, truncated_count = annotate_truncated_values(output)
82
+ if truncated_count > 0
83
+ total_truncated += truncated_count
84
+ title += " (#{truncated_count} truncated)"
85
+ end
86
+ end
87
+
88
+ # Summarize long call stacks by collapsing framework frames
89
+ if command == "bt"
90
+ bt_raw = output
91
+ output = summarize_call_stack(output)
92
+ end
93
+
94
+ parts << "=== #{title} ===\n#{output}"
95
+ rescue DebugMcp::TimeoutError
96
+ parts << "=== #{title} ===\n(timed out)"
97
+ end
98
+
99
+ # Annotate return events: the return value is shown in bt output (#=>)
100
+ # and in local variables (%return). Add a note so the agent understands
101
+ # the current line has already executed.
102
+ if bt_raw && return_event_frame?(bt_raw)
103
+ return_note = "=== Stop Event: Return ===\n" \
104
+ "The current line (=>) has ALREADY been executed. " \
105
+ "You are seeing the state AFTER this line ran.\n" \
106
+ "Return value is shown in Call Stack (#=>) and Local Variables (%return)."
107
+
108
+ # Check if the return is due to an exception
109
+ begin
110
+ if (exception_info = client.check_current_exception)
111
+ return_note += "\n\nException in scope: #{exception_info}\n" \
112
+ "This method/block is returning due to an exception, not a normal return."
113
+ end
114
+ rescue DebugMcp::Error
115
+ # Best-effort
116
+ end
117
+
118
+ parts << return_note
119
+ end
120
+
121
+ if total_truncated > 0
122
+ parts << "---\n#{total_truncated} variable(s) have truncated values. " \
123
+ "Use 'inspect_object' to see full contents (e.g., inspect_object(expression: 'variable_name'))."
124
+ else
125
+ parts << "---\nTip: Use 'evaluate_code' or 'inspect_object' for detailed variable inspection."
126
+ end
127
+
128
+ if (http_note = PendingHttpHelper.pending_http_note(client))
129
+ parts << http_note
130
+ end
131
+
132
+ MCP::Tool::Response.new([{ type: "text", text: parts.join("\n\n") }])
133
+ rescue DebugMcp::Error => e
134
+ MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
135
+ end
136
+
137
+ private
138
+
139
+ # Check if the current frame (=>) in bt output indicates a return event.
140
+ # The debug gem appends "#=>" to the frame line at return/b_return/c_return events:
141
+ # =>#0 Class#method at file.rb:10 #=> return_value
142
+ def return_event_frame?(bt_output)
143
+ bt_output.each_line do |line|
144
+ return line.include?("#=>") if line.include?("=>#")
145
+ end
146
+ false
147
+ end
148
+
149
+ # Summarize a long call stack by collapsing consecutive framework frames.
150
+ # App code frames are preserved; gem/internal frames are collapsed into
151
+ # summary lines like " ... 12 framework frames (actionpack, rack, puma) ..."
152
+ def summarize_call_stack(output)
153
+ lines = output.lines
154
+ return output if lines.size <= 15
155
+
156
+ result = []
157
+ gem_group = []
158
+
159
+ lines.each do |line|
160
+ if framework_frame?(line)
161
+ gem_group << line
162
+ else
163
+ if gem_group.any?
164
+ result << collapse_gem_frames(gem_group)
165
+ gem_group = []
166
+ end
167
+ result << line
168
+ end
169
+ end
170
+ result << collapse_gem_frames(gem_group) if gem_group.any?
171
+
172
+ result.join
173
+ end
174
+
175
+ def framework_frame?(line)
176
+ line.match?(FRAMEWORK_PATH_PATTERN)
177
+ end
178
+
179
+ def collapse_gem_frames(frames)
180
+ # Extract gem names from paths like /gems/actionpack-7.0.0/lib/...
181
+ # Gem names always start with a letter, so we skip numeric directories
182
+ # like /gems/3.3.0/ in paths such as ruby/gems/3.3.0/gems/actionpack-7.0.0/
183
+ gem_names = frames.filter_map { |f|
184
+ if (m = f.match(%r{/gems/([a-zA-Z][^/]*?)(?:-\d[\d.]*)?/}))
185
+ m[1]
186
+ elsif f.include?("[C]")
187
+ "C"
188
+ end
189
+ }.uniq.sort
190
+
191
+ label = gem_names.empty? ? "framework" : gem_names.join(", ")
192
+ count_label = frames.size == 1 ? "1 framework frame" : "#{frames.size} framework frames"
193
+ " ... #{count_label} (#{label}) ...\n"
194
+ end
195
+
196
+ # Annotate truncated variable lines with [truncated] marker.
197
+ # Returns [annotated_output, truncated_count].
198
+ def annotate_truncated_values(output)
199
+ truncated_count = 0
200
+ annotated = output.each_line.map do |line|
201
+ # Variable lines in `info locals`/`info ivars` follow the pattern:
202
+ # name = value
203
+ # Only check lines that contain " = " (variable assignments)
204
+ if line.include?(" = ") && line.match?(TRUNCATION_PATTERN)
205
+ truncated_count += 1
206
+ "#{line.chomp} [truncated]\n"
207
+ else
208
+ line
209
+ end
210
+ end.join
211
+
212
+ [annotated, truncated_count]
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "../rails_helper"
5
+
6
+ module DebugMcp
7
+ module Tools
8
+ class GetSource < MCP::Tool
9
+ MAX_SOURCE_LINES = 50
10
+
11
+ description "[Investigation] Get the source code of a method or class from the running process. " \
12
+ "Use 'Class#method' for instance methods, 'Class.method' for class methods, " \
13
+ "or 'Class' for class info including ancestors and method lists."
14
+
15
+ annotations(
16
+ title: "Get Source Code",
17
+ read_only_hint: true,
18
+ destructive_hint: false,
19
+ open_world_hint: false,
20
+ )
21
+
22
+ input_schema(
23
+ properties: {
24
+ target: {
25
+ type: "string",
26
+ description: "Method or class to get source for (e.g., 'User#save', 'User.find', 'User')",
27
+ },
28
+ session_id: {
29
+ type: "string",
30
+ description: "Debug session ID (uses default session if omitted)",
31
+ },
32
+ },
33
+ required: ["target"],
34
+ )
35
+
36
+ class << self
37
+ def call(target:, session_id: nil, server_context:)
38
+ client = server_context[:session_manager].client(session_id)
39
+ client.auto_repause!
40
+
41
+ if target.include?("#") || target.include?(".")
42
+ get_method_source(client, target)
43
+ else
44
+ get_class_info(client, target)
45
+ end
46
+ rescue DebugMcp::Error => e
47
+ MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
48
+ end
49
+
50
+ private
51
+
52
+ def get_method_source(client, target)
53
+ if target.include?("#")
54
+ class_name, method_name = target.split("#", 2)
55
+ method_ref = "#{class_name}.instance_method(:#{method_name})"
56
+ else
57
+ class_name, method_name = target.split(".", 2)
58
+ method_ref = "#{class_name}.method(:#{method_name})"
59
+ end
60
+
61
+ # Get source_location and parameters in one eval
62
+ info_code = "[#{method_ref}.source_location, #{method_ref}.parameters]"
63
+ raw = client.send_command("p #{info_code}").strip.sub(/\A=>\s*/, "")
64
+
65
+ if raw == "nil" || raw.start_with?("[nil")
66
+ return MCP::Tool::Response.new([{
67
+ type: "text",
68
+ text: "#{target}: source not available (native or C extension method)",
69
+ }])
70
+ end
71
+
72
+ # Parse source_location from output: [["file.rb", 26], [[:req, :name], ...]]
73
+ # Use a second eval to get clean values
74
+ file_output = client.send_command("p #{method_ref}.source_location[0]").strip.sub(/\A=>\s*/, "")
75
+ line_output = client.send_command("p #{method_ref}.source_location[1]").strip.sub(/\A=>\s*/, "")
76
+ params_output = client.send_command("p #{method_ref}.parameters").strip.sub(/\A=>\s*/, "")
77
+
78
+ file = file_output.delete('"')
79
+ line = line_output.to_i
80
+
81
+ # Read source directly from filesystem, or via remote if Docker/remote connection
82
+ source = read_method_source(client, file, line)
83
+
84
+ parts = [target]
85
+ parts << " File: #{file}:#{line}"
86
+ parts << " Parameters: #{params_output}" unless params_output.empty?
87
+ if source
88
+ parts << ""
89
+ parts << source
90
+ end
91
+
92
+ MCP::Tool::Response.new([{ type: "text", text: parts.join("\n") }])
93
+ end
94
+
95
+ def get_class_info(client, target)
96
+ parts = []
97
+
98
+ name = client.send_command("p #{target}.name").strip.sub(/\A=>\s*/, "")
99
+ parts << "Class: #{name}"
100
+
101
+ ancestors = client.send_command("p #{target}.ancestors.first(10).map(&:to_s)").strip.sub(/\A=>\s*/, "")
102
+ parts << "Ancestors: #{ancestors}"
103
+
104
+ imethods = client.send_command("p #{target}.instance_methods(false).sort.first(30)").strip.sub(/\A=>\s*/, "")
105
+ parts << "Instance methods: #{imethods}"
106
+
107
+ cmethods = client.send_command("p (#{target}.methods - Class.methods).sort.first(30)").strip.sub(/\A=>\s*/, "")
108
+ parts << "Class methods: #{cmethods}"
109
+
110
+ MCP::Tool::Response.new([{ type: "text", text: parts.join("\n") }])
111
+ end
112
+
113
+ def read_method_source(client, file, start_line)
114
+ if File.exist?(file)
115
+ read_method_source_local(file, start_line)
116
+ elsif client.remote
117
+ read_method_source_remote(client, file, start_line)
118
+ end
119
+ rescue StandardError
120
+ nil
121
+ end
122
+
123
+ def read_method_source_local(file, start_line)
124
+ lines = File.readlines(file)
125
+ return nil if start_line < 1 || start_line > lines.length
126
+
127
+ end_line = find_method_end(lines, start_line - 1)
128
+ selected = lines[(start_line - 1)..end_line]
129
+
130
+ selected.map.with_index(start_line) { |line, num| " #{num.to_s.rjust(4)}| #{line}" }.join
131
+ end
132
+
133
+ # Max lines to fetch per chunk via debug session
134
+ REMOTE_CHUNK_SIZE = 50
135
+
136
+ def read_method_source_remote(client, file, start_line)
137
+ return nil if start_line < 1
138
+
139
+ # Get total line count from remote
140
+ count_str = RailsHelper.eval_expr(client, "File.readlines(#{file.inspect}).size")
141
+ return nil unless count_str
142
+
143
+ total_lines = count_str.to_i
144
+ return nil if start_line > total_lines
145
+
146
+ # Fetch enough lines to find method end (start_line through start_line + MAX_SOURCE_LINES)
147
+ fetch_start = start_line - 1
148
+ fetch_end = [fetch_start + MAX_SOURCE_LINES, total_lines - 1].min
149
+
150
+ all_lines = []
151
+ pos = fetch_start
152
+ while pos <= fetch_end
153
+ chunk_end = [pos + REMOTE_CHUNK_SIZE - 1, fetch_end].min
154
+ chunk = RailsHelper.eval_expr(client,
155
+ "File.readlines(#{file.inspect})[#{pos}..#{chunk_end}].join")
156
+ break unless chunk
157
+
158
+ all_lines << chunk
159
+ pos = chunk_end + 1
160
+ end
161
+
162
+ return nil if all_lines.empty?
163
+
164
+ lines = all_lines.join.lines
165
+ end_index = find_method_end(lines, 0)
166
+ selected = lines[0..end_index]
167
+
168
+ selected.map.with_index(start_line) { |line, num| " #{num.to_s.rjust(4)}| #{line}" }.join
169
+ end
170
+
171
+ # girb本家のロジックを流用: インデント解析でメソッド終端を探す
172
+ # NOTE: girb-core切り出し時の共通化候補
173
+ def find_method_end(lines, start_index)
174
+ return [start_index + MAX_SOURCE_LINES, lines.length - 1].min if start_index >= lines.length
175
+
176
+ base_indent = lines[start_index][/^\s*/].length
177
+
178
+ (start_index + 1).upto([start_index + MAX_SOURCE_LINES, lines.length - 1].min) do |i|
179
+ line = lines[i]
180
+ next if line.strip.empty? || line.strip.start_with?("#")
181
+
182
+ current_indent = line[/^\s*/].length
183
+ if current_indent <= base_indent && line.strip.start_with?("end")
184
+ return i
185
+ end
186
+ end
187
+
188
+ [start_index + MAX_SOURCE_LINES, lines.length - 1].min
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "../pending_http_helper"
5
+
6
+ module DebugMcp
7
+ module Tools
8
+ class InspectObject < MCP::Tool
9
+ description "[Investigation] Deep-inspect a Ruby object in the paused process. " \
10
+ "Returns the value, class, and instance variables. " \
11
+ "More detailed than evaluate_code — use this to understand an object's internal state."
12
+
13
+ annotations(
14
+ title: "Inspect Object",
15
+ read_only_hint: true,
16
+ destructive_hint: false,
17
+ open_world_hint: false,
18
+ )
19
+
20
+ input_schema(
21
+ properties: {
22
+ expression: {
23
+ type: "string",
24
+ description: "Variable name or Ruby expression to inspect (e.g., 'user', '@items.first')",
25
+ },
26
+ session_id: {
27
+ type: "string",
28
+ description: "Debug session ID (uses default session if omitted)",
29
+ },
30
+ },
31
+ required: ["expression"],
32
+ )
33
+
34
+ class << self
35
+ def call(expression:, session_id: nil, server_context:)
36
+ client = server_context[:session_manager].client(session_id)
37
+ client.auto_repause!
38
+
39
+ parts = []
40
+
41
+ # RT 1: Get the pretty-printed value (primary - if this fails, expression is invalid)
42
+ value_output = client.send_command("pp #{expression}")
43
+ parts << "Value:\n#{value_output}"
44
+
45
+ # RT 2: Get class + instance variables (+ class variables if Module) in a single command
46
+ begin
47
+ meta_output = client.send_command(
48
+ "p [(#{expression}).class.to_s, (#{expression}).instance_variables, " \
49
+ "(#{expression}).is_a?(Module) ? (#{expression}).class_variables : nil]",
50
+ )
51
+ class_name, ivars, cvars = parse_meta(meta_output)
52
+ parts << "Class: #{class_name}" if class_name
53
+ parts << "Instance variables: #{ivars}" if ivars
54
+
55
+ # RT 3: Get class variable values (only for Module/Class with class variables)
56
+ if cvars && cvars != "[]"
57
+ begin
58
+ cvar_values = client.send_command(
59
+ "pp Hash[(#{expression}).class_variables.map{|v|" \
60
+ "[v,begin;(#{expression}).class_variable_get(v);rescue;'(error)';end]}]",
61
+ )
62
+ parts << "Class variables:\n#{cvar_values}"
63
+ rescue DebugMcp::TimeoutError
64
+ parts << "Class variables: #{cvars}"
65
+ end
66
+ elsif cvars
67
+ parts << "Class variables: #{cvars}"
68
+ end
69
+ rescue DebugMcp::TimeoutError
70
+ parts << "Class: (timed out)"
71
+ parts << "Instance variables: (timed out)"
72
+ end
73
+
74
+ text = parts.join("\n\n")
75
+ text = append_trap_context_note(client, text)
76
+ if (http_note = PendingHttpHelper.pending_http_note(client))
77
+ text = "#{text}\n\n#{http_note}"
78
+ end
79
+ MCP::Tool::Response.new([{ type: "text", text: text }])
80
+ rescue DebugMcp::Error => e
81
+ MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
82
+ end
83
+
84
+ private
85
+
86
+ # Parse combined meta output: => ["ClassName", [:@ivar1, :@ivar2], [:@@cvar1] or nil]
87
+ # Returns [class_name, ivars_string, cvars_string_or_nil] or falls back to raw output.
88
+ def parse_meta(output)
89
+ cleaned = output.strip.sub(/\A=> /, "")
90
+ # Match: ["ClassName", [...], [...] or nil]
91
+ if (match = cleaned.match(/\A\["([^"]*)",\s*(\[.*?\]),\s*(nil|\[.*?\])\]\z/))
92
+ cvars = match[3] == "nil" ? nil : match[3]
93
+ [match[1], match[2], cvars]
94
+ else
95
+ # Fallback: return raw output as class info
96
+ [cleaned, nil, nil]
97
+ end
98
+ end
99
+
100
+ def append_trap_context_note(client, text)
101
+ return text unless client.respond_to?(:trap_context) && client.trap_context
102
+ "#{text}\n\n[trap context]"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module DebugMcp
6
+ module Tools
7
+ class ListDebugSessions < MCP::Tool
8
+ description "[Discovery] List available Ruby debug sessions on this machine. " \
9
+ "Shows running Ruby processes started with 'rdbg --open' that can be " \
10
+ "connected to. Use this first to find sessions before calling 'connect'."
11
+
12
+ annotations(
13
+ title: "List Debug Sessions",
14
+ read_only_hint: true,
15
+ destructive_hint: false,
16
+ open_world_hint: false,
17
+ )
18
+
19
+ input_schema(
20
+ properties: {},
21
+ )
22
+
23
+ class << self
24
+ def call(server_context:)
25
+ unix_sessions = DebugClient.list_sessions
26
+ tcp_sessions = TcpSessionDiscovery.discover
27
+
28
+ if unix_sessions.empty? && tcp_sessions.empty?
29
+ text = "No debug sessions found.\n\n" \
30
+ "To start a debuggable Ruby process:\n" \
31
+ " rdbg --open <script.rb>\n" \
32
+ " rdbg --open --port=12345 <script.rb>\n" \
33
+ " RUBY_DEBUG_OPEN=true ruby <script.rb>\n\n" \
34
+ "For Docker containers:\n" \
35
+ " docker run -e RUBY_DEBUG_OPEN=true -e RUBY_DEBUG_HOST=0.0.0.0 " \
36
+ "-e RUBY_DEBUG_PORT=12345 -p 12345:12345 <image>"
37
+ else
38
+ lines = []
39
+
40
+ unix_sessions.each do |s|
41
+ name_info = s[:name] ? " (#{s[:name]})" : ""
42
+ lines << " PID #{s[:pid]}#{name_info}: #{s[:path]}"
43
+ end
44
+
45
+ tcp_sessions.each do |s|
46
+ source_label = s[:source] == :docker ? "Docker" : "TCP"
47
+ lines << " #{source_label} \"#{s[:name]}\": #{s[:host]}:#{s[:port]} " \
48
+ "(connect with port: #{s[:port]})"
49
+ end
50
+
51
+ total = unix_sessions.size + tcp_sessions.size
52
+ text = "Found #{total} debug session(s):\n#{lines.join("\n")}"
53
+ end
54
+
55
+ MCP::Tool::Response.new([{ type: "text", text: text }])
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end