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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE +21 -0
- data/README.ja.md +383 -0
- data/README.md +384 -0
- data/examples/01_simple_bug.rb +43 -0
- data/examples/02_data_pipeline.rb +93 -0
- data/examples/03_recursion.rb +96 -0
- data/examples/RAILS_SCENARIOS.md +350 -0
- data/examples/SCENARIOS.md +142 -0
- data/examples/rails_test_app/setup.sh +428 -0
- data/examples/rails_test_app/testapp/.dockerignore +10 -0
- data/examples/rails_test_app/testapp/.ruby-version +1 -0
- data/examples/rails_test_app/testapp/Dockerfile +23 -0
- data/examples/rails_test_app/testapp/Gemfile +17 -0
- data/examples/rails_test_app/testapp/README.md +65 -0
- data/examples/rails_test_app/testapp/Rakefile +6 -0
- data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
- data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +1 -0
- data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +4 -0
- data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +38 -0
- data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +11 -0
- data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +100 -0
- data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +82 -0
- data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +25 -0
- data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +44 -0
- data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +2 -0
- data/examples/rails_test_app/testapp/app/models/application_record.rb +3 -0
- data/examples/rails_test_app/testapp/app/models/comment.rb +8 -0
- data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/models/order.rb +56 -0
- data/examples/rails_test_app/testapp/app/models/order_item.rb +16 -0
- data/examples/rails_test_app/testapp/app/models/post.rb +29 -0
- data/examples/rails_test_app/testapp/app/models/user.rb +34 -0
- data/examples/rails_test_app/testapp/app/services/order_report_service.rb +40 -0
- data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +28 -0
- data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +22 -0
- data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +26 -0
- data/examples/rails_test_app/testapp/bin/ci +6 -0
- data/examples/rails_test_app/testapp/bin/dev +2 -0
- data/examples/rails_test_app/testapp/bin/rails +4 -0
- data/examples/rails_test_app/testapp/bin/rake +4 -0
- data/examples/rails_test_app/testapp/bin/setup +35 -0
- data/examples/rails_test_app/testapp/config/application.rb +42 -0
- data/examples/rails_test_app/testapp/config/boot.rb +3 -0
- data/examples/rails_test_app/testapp/config/ci.rb +14 -0
- data/examples/rails_test_app/testapp/config/database.yml +32 -0
- data/examples/rails_test_app/testapp/config/environment.rb +5 -0
- data/examples/rails_test_app/testapp/config/environments/development.rb +54 -0
- data/examples/rails_test_app/testapp/config/environments/production.rb +67 -0
- data/examples/rails_test_app/testapp/config/environments/test.rb +42 -0
- data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +29 -0
- data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/rails_test_app/testapp/config/initializers/inflections.rb +16 -0
- data/examples/rails_test_app/testapp/config/locales/en.yml +31 -0
- data/examples/rails_test_app/testapp/config/puma.rb +39 -0
- data/examples/rails_test_app/testapp/config/routes.rb +34 -0
- data/examples/rails_test_app/testapp/config.ru +6 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +12 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +13 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +11 -0
- data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +14 -0
- data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +13 -0
- data/examples/rails_test_app/testapp/db/schema.rb +71 -0
- data/examples/rails_test_app/testapp/db/seeds.rb +85 -0
- data/examples/rails_test_app/testapp/docker-compose.yml +21 -0
- data/examples/rails_test_app/testapp/docker-entrypoint.sh +10 -0
- 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 +135 -0
- data/examples/rails_test_app/testapp/public/404.html +135 -0
- data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +135 -0
- data/examples/rails_test_app/testapp/public/422.html +135 -0
- data/examples/rails_test_app/testapp/public/500.html +135 -0
- data/examples/rails_test_app/testapp/public/icon.png +0 -0
- data/examples/rails_test_app/testapp/public/icon.svg +3 -0
- data/examples/rails_test_app/testapp/public/robots.txt +1 -0
- 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
- data/exe/debug-mcp +39 -0
- data/exe/debug-rails +127 -0
- data/lib/debug_mcp/client_cleanup.rb +102 -0
- data/lib/debug_mcp/code_safety_analyzer.rb +124 -0
- data/lib/debug_mcp/debug_client.rb +1143 -0
- data/lib/debug_mcp/exit_message_builder.rb +112 -0
- data/lib/debug_mcp/pending_http_helper.rb +25 -0
- data/lib/debug_mcp/rails_helper.rb +155 -0
- data/lib/debug_mcp/server.rb +364 -0
- data/lib/debug_mcp/session_manager.rb +436 -0
- data/lib/debug_mcp/stop_event_annotator.rb +152 -0
- data/lib/debug_mcp/tcp_session_discovery.rb +226 -0
- data/lib/debug_mcp/tools/connect.rb +669 -0
- data/lib/debug_mcp/tools/continue_execution.rb +161 -0
- data/lib/debug_mcp/tools/disconnect.rb +169 -0
- data/lib/debug_mcp/tools/evaluate_code.rb +354 -0
- data/lib/debug_mcp/tools/finish.rb +84 -0
- data/lib/debug_mcp/tools/get_context.rb +217 -0
- data/lib/debug_mcp/tools/get_source.rb +193 -0
- data/lib/debug_mcp/tools/inspect_object.rb +107 -0
- data/lib/debug_mcp/tools/list_debug_sessions.rb +60 -0
- data/lib/debug_mcp/tools/list_files.rb +189 -0
- data/lib/debug_mcp/tools/list_paused_sessions.rb +108 -0
- data/lib/debug_mcp/tools/next.rb +70 -0
- data/lib/debug_mcp/tools/rails_info.rb +200 -0
- data/lib/debug_mcp/tools/rails_model.rb +362 -0
- data/lib/debug_mcp/tools/rails_routes.rb +186 -0
- data/lib/debug_mcp/tools/read_file.rb +214 -0
- data/lib/debug_mcp/tools/remove_breakpoint.rb +173 -0
- data/lib/debug_mcp/tools/run_debug_command.rb +55 -0
- data/lib/debug_mcp/tools/run_script.rb +293 -0
- data/lib/debug_mcp/tools/set_breakpoint.rb +206 -0
- data/lib/debug_mcp/tools/step.rb +67 -0
- data/lib/debug_mcp/tools/trigger_request.rb +515 -0
- data/lib/debug_mcp/version.rb +5 -0
- data/lib/debug_mcp.rb +40 -0
- 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
|