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,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../rails_helper"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
module Tools
|
|
8
|
+
class ListFiles < MCP::Tool
|
|
9
|
+
MAX_ENTRIES = 500
|
|
10
|
+
|
|
11
|
+
description "[Investigation] List files and directories in a path from the debug session's machine. " \
|
|
12
|
+
"Use this to explore directory structure, find source files, or locate configuration. " \
|
|
13
|
+
"Relative paths are resolved against the debugged process's working directory."
|
|
14
|
+
|
|
15
|
+
annotations(
|
|
16
|
+
title: "List Files",
|
|
17
|
+
read_only_hint: true,
|
|
18
|
+
destructive_hint: false,
|
|
19
|
+
open_world_hint: false,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
input_schema(
|
|
23
|
+
properties: {
|
|
24
|
+
path: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Directory path to list (relative to working directory or absolute)",
|
|
27
|
+
},
|
|
28
|
+
pattern: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Optional glob pattern to filter entries (e.g., '*.rb', '**/*.yml'). " \
|
|
31
|
+
"When omitted, lists immediate children of the directory.",
|
|
32
|
+
},
|
|
33
|
+
session_id: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["path"],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def call(path:, pattern: nil, session_id: nil, server_context:)
|
|
43
|
+
client = get_client(server_context, session_id)
|
|
44
|
+
|
|
45
|
+
if client&.remote
|
|
46
|
+
return list_remote(client, path, pattern)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
full_path = resolve_path(path, server_context, session_id)
|
|
50
|
+
list_local(full_path, pattern)
|
|
51
|
+
rescue DebugMcp::Error => e
|
|
52
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def get_client(server_context, session_id)
|
|
58
|
+
if session_id
|
|
59
|
+
server_context[:session_manager].get(session_id).client
|
|
60
|
+
else
|
|
61
|
+
server_context[:session_manager].client
|
|
62
|
+
end
|
|
63
|
+
rescue DebugMcp::Error
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolve_path(path, server_context, session_id)
|
|
68
|
+
return File.expand_path(path) if path.start_with?("/") || path.start_with?("~")
|
|
69
|
+
|
|
70
|
+
cwd = remote_cwd(server_context, session_id)
|
|
71
|
+
if cwd
|
|
72
|
+
File.join(cwd, path)
|
|
73
|
+
else
|
|
74
|
+
File.expand_path(path)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def remote_cwd(server_context, session_id)
|
|
79
|
+
client = if session_id
|
|
80
|
+
server_context[:session_manager].get(session_id).client
|
|
81
|
+
else
|
|
82
|
+
server_context[:session_manager].client
|
|
83
|
+
end
|
|
84
|
+
client.auto_repause!
|
|
85
|
+
result = client.send_command("p Dir.pwd")
|
|
86
|
+
cleaned = result.strip.sub(/\A=> /, "")
|
|
87
|
+
return nil if cleaned == "nil" || cleaned.empty?
|
|
88
|
+
|
|
89
|
+
if cleaned.start_with?('"') && cleaned.end_with?('"')
|
|
90
|
+
cleaned = cleaned[1..-2]
|
|
91
|
+
end
|
|
92
|
+
cleaned.empty? ? nil : cleaned
|
|
93
|
+
rescue DebugMcp::Error
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def list_local(full_path, pattern)
|
|
98
|
+
unless Dir.exist?(full_path)
|
|
99
|
+
return MCP::Tool::Response.new([{ type: "text",
|
|
100
|
+
text: "Error: Directory not found: #{full_path}" }])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if pattern
|
|
104
|
+
glob_path = File.join(full_path, pattern)
|
|
105
|
+
entries = Dir.glob(glob_path).sort
|
|
106
|
+
else
|
|
107
|
+
entries = Dir.children(full_path).sort.map { |name| File.join(full_path, name) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
format_entries(full_path, entries, pattern)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def list_remote(client, path, pattern)
|
|
114
|
+
client.auto_repause!
|
|
115
|
+
|
|
116
|
+
# Resolve relative path
|
|
117
|
+
full_path = if path.start_with?("/")
|
|
118
|
+
path
|
|
119
|
+
else
|
|
120
|
+
cwd = RailsHelper.eval_expr(client, "Dir.pwd")
|
|
121
|
+
cwd ? File.join(cwd, path) : path
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check directory exists
|
|
125
|
+
exists = RailsHelper.eval_expr(client, "Dir.exist?(#{full_path.inspect})")
|
|
126
|
+
unless exists == "true"
|
|
127
|
+
return MCP::Tool::Response.new([{ type: "text",
|
|
128
|
+
text: "Error: Directory not found on remote process: #{full_path}" }])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if pattern
|
|
132
|
+
expr = "Dir.glob(File.join(#{full_path.inspect}, #{pattern.inspect})).sort.first(#{MAX_ENTRIES + 1})" \
|
|
133
|
+
".map{|f| (File.directory?(f) ? 'd:' : 'f:') + f }.join(\"\\n\")"
|
|
134
|
+
else
|
|
135
|
+
expr = "Dir.children(#{full_path.inspect}).sort.first(#{MAX_ENTRIES + 1})" \
|
|
136
|
+
".map{|n| f=File.join(#{full_path.inspect},n); (File.directory?(f) ? 'd:' : 'f:') + f }.join(\"\\n\")"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
result = RailsHelper.eval_expr(client, expr)
|
|
140
|
+
unless result
|
|
141
|
+
return MCP::Tool::Response.new([{ type: "text",
|
|
142
|
+
text: "Error: Failed to list directory on remote process: #{full_path}" }])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
raw_entries = result.split("\n").reject(&:empty?)
|
|
146
|
+
truncated = raw_entries.length > MAX_ENTRIES
|
|
147
|
+
raw_entries = raw_entries.first(MAX_ENTRIES) if truncated
|
|
148
|
+
|
|
149
|
+
lines = []
|
|
150
|
+
raw_entries.each do |entry|
|
|
151
|
+
if entry.start_with?("d:")
|
|
152
|
+
lines << "[dir] #{entry[2..]}"
|
|
153
|
+
else
|
|
154
|
+
lines << "[file] #{entry[2..]}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
header = "#{full_path} (#{raw_entries.length} entries) [remote]"
|
|
159
|
+
header += " (truncated to #{MAX_ENTRIES})" if truncated
|
|
160
|
+
text = "#{header}\n\n#{lines.join("\n")}"
|
|
161
|
+
|
|
162
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
163
|
+
rescue DebugMcp::Error => e
|
|
164
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error listing remote directory: #{e.message}" }])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def format_entries(full_path, entries, pattern)
|
|
168
|
+
truncated = entries.length > MAX_ENTRIES
|
|
169
|
+
entries = entries.first(MAX_ENTRIES) if truncated
|
|
170
|
+
|
|
171
|
+
lines = entries.map do |entry|
|
|
172
|
+
if File.directory?(entry)
|
|
173
|
+
"[dir] #{entry}"
|
|
174
|
+
else
|
|
175
|
+
"[file] #{entry}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
header = "#{full_path} (#{entries.length} entries)"
|
|
180
|
+
header += " matching '#{pattern}'" if pattern
|
|
181
|
+
header += " (truncated to #{MAX_ENTRIES})" if truncated
|
|
182
|
+
text = "#{header}\n\n#{lines.join("\n")}"
|
|
183
|
+
|
|
184
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module DebugMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class ListPausedSessions < MCP::Tool
|
|
8
|
+
description "[Discovery] List all active debug sessions managed by debug-mcp. " \
|
|
9
|
+
"Shows connected sessions, their PIDs, and idle time."
|
|
10
|
+
|
|
11
|
+
annotations(
|
|
12
|
+
title: "List Active Sessions",
|
|
13
|
+
read_only_hint: true,
|
|
14
|
+
destructive_hint: false,
|
|
15
|
+
open_world_hint: false,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
input_schema(
|
|
19
|
+
properties: {},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def call(server_context:)
|
|
24
|
+
manager = server_context[:session_manager]
|
|
25
|
+
sessions = manager.active_sessions(include_client: true)
|
|
26
|
+
|
|
27
|
+
if sessions.empty?
|
|
28
|
+
text = "No active debug sessions. Use 'connect' or 'run_script' to start one."
|
|
29
|
+
else
|
|
30
|
+
lines = sessions.map { |s| format_session(s) }
|
|
31
|
+
text = "Active debug sessions:\n#{lines.join("\n")}"
|
|
32
|
+
text += "\n\nNote: Sessions expire after inactivity. Any tool call resets the timer."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def format_session(s)
|
|
41
|
+
status = s[:connected] ? "connected" : "disconnected"
|
|
42
|
+
status += ", paused" if s[:paused]
|
|
43
|
+
idle = format_duration(s[:idle_seconds])
|
|
44
|
+
|
|
45
|
+
line = " #{s[:session_id]} (PID: #{s[:pid]}, #{status}, idle: #{idle})"
|
|
46
|
+
|
|
47
|
+
# Show remaining time before timeout
|
|
48
|
+
if s[:timeout_seconds]
|
|
49
|
+
remaining = s[:timeout_seconds] - s[:idle_seconds]
|
|
50
|
+
if remaining > 300
|
|
51
|
+
line += "\n Timeout: #{format_duration(remaining)} remaining"
|
|
52
|
+
elsif remaining > 0
|
|
53
|
+
line += "\n Timeout: #{format_duration(remaining)} remaining (WARNING: expiring soon)"
|
|
54
|
+
else
|
|
55
|
+
line += "\n Timeout: EXPIRED (will be reaped on next check)"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Query current stop location and breakpoint count from the client
|
|
60
|
+
client = s[:client]
|
|
61
|
+
if client&.connected? && client.paused
|
|
62
|
+
location = query_stop_location(client)
|
|
63
|
+
line += "\n Location: #{location}" if location
|
|
64
|
+
|
|
65
|
+
bp_count = query_breakpoint_count(client)
|
|
66
|
+
line += "\n Breakpoints: #{bp_count}" if bp_count
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
line
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Query the current stop location from the debug session.
|
|
73
|
+
# Returns "file.rb:10 in ClassName#method" or nil.
|
|
74
|
+
def query_stop_location(client)
|
|
75
|
+
output = client.send_command("frame")
|
|
76
|
+
if (match = output.match(/#\d+\s+(.+?)\s+at\s+(.+:\d+)/))
|
|
77
|
+
"#{match[2]} in #{match[1].strip}"
|
|
78
|
+
end
|
|
79
|
+
rescue DebugMcp::Error
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Query the number of breakpoints set.
|
|
84
|
+
# Returns a string like "3 set" or nil.
|
|
85
|
+
def query_breakpoint_count(client)
|
|
86
|
+
output = client.send_command("info breakpoints")
|
|
87
|
+
cleaned = output.strip
|
|
88
|
+
return nil if cleaned.empty? || cleaned.include?("No breakpoints")
|
|
89
|
+
|
|
90
|
+
count = cleaned.lines.count { |l| l.match?(/\A\s*#\d+/) }
|
|
91
|
+
count > 0 ? "#{count} set" : nil
|
|
92
|
+
rescue DebugMcp::Error
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format_duration(seconds)
|
|
97
|
+
if seconds < 60
|
|
98
|
+
"#{seconds}s"
|
|
99
|
+
elsif seconds < 3600
|
|
100
|
+
"#{seconds / 60}m #{seconds % 60}s"
|
|
101
|
+
else
|
|
102
|
+
"#{seconds / 3600}h #{(seconds % 3600) / 60}m"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module DebugMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class Next < MCP::Tool
|
|
8
|
+
description "[Control] Step over to the next line without entering method calls. " \
|
|
9
|
+
"Use 'step' instead to enter called methods. " \
|
|
10
|
+
"Inside a block (e.g., each, map, reject), 'next' advances within the current " \
|
|
11
|
+
"block iteration. To skip to the NEXT ITERATION, use set_breakpoint with one_shot: true " \
|
|
12
|
+
"on the first line of the block body, then continue_execution. " \
|
|
13
|
+
"Use 'finish' to exit the current block/method entirely. " \
|
|
14
|
+
"If an exception is raised and rescued during the step, it will be reported automatically."
|
|
15
|
+
|
|
16
|
+
annotations(
|
|
17
|
+
title: "Step Over",
|
|
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("next")
|
|
37
|
+
|
|
38
|
+
if output.strip.empty? && client.process_finished?
|
|
39
|
+
text = DebugMcp::ExitMessageBuilder.build_exit_message(
|
|
40
|
+
"Program exited during step.", 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
|
+
MCP::Tool::Response.new([{ type: "text", text: output }])
|
|
50
|
+
rescue DebugMcp::SessionError => e
|
|
51
|
+
text = if e.message.include?("session ended") || e.message.include?("finished execution")
|
|
52
|
+
DebugMcp::ExitMessageBuilder.build_exit_message("Program exited during step.", e.final_output, client)
|
|
53
|
+
else
|
|
54
|
+
"Error: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
57
|
+
rescue DebugMcp::ConnectionError => e
|
|
58
|
+
text = if e.message.include?("Connection lost") || e.message.include?("connection closed")
|
|
59
|
+
DebugMcp::ExitMessageBuilder.build_exit_message("Program exited during step.", e.final_output, client)
|
|
60
|
+
else
|
|
61
|
+
"Error: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
64
|
+
rescue DebugMcp::Error => e
|
|
65
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "../rails_helper"
|
|
6
|
+
|
|
7
|
+
module DebugMcp
|
|
8
|
+
module Tools
|
|
9
|
+
class RailsInfo < MCP::Tool
|
|
10
|
+
description "[Investigation] Show Rails application overview: app name, Rails/Ruby versions, " \
|
|
11
|
+
"environment, and root path. Also shows database configuration and route count " \
|
|
12
|
+
"when available (these require escaping trap context first on Puma/threaded servers). " \
|
|
13
|
+
"Use this after connecting to a Rails process to quickly understand the application."
|
|
14
|
+
|
|
15
|
+
annotations(
|
|
16
|
+
title: "Rails App Info",
|
|
17
|
+
read_only_hint: true,
|
|
18
|
+
destructive_hint: false,
|
|
19
|
+
open_world_hint: false,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
input_schema(
|
|
23
|
+
properties: {
|
|
24
|
+
session_id: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
SENSITIVE_KEYS = %w[password secret secret_key_base secret_key].freeze
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def call(session_id: nil, server_context:)
|
|
35
|
+
client = server_context[:session_manager].client(session_id)
|
|
36
|
+
client.auto_repause!
|
|
37
|
+
RailsHelper.require_rails!(client)
|
|
38
|
+
|
|
39
|
+
parts = []
|
|
40
|
+
|
|
41
|
+
# App name, Rails version, environment
|
|
42
|
+
parts << build_app_section(client)
|
|
43
|
+
|
|
44
|
+
# Root path
|
|
45
|
+
parts << build_root_section(client)
|
|
46
|
+
|
|
47
|
+
# Database configuration
|
|
48
|
+
parts << build_db_section(client)
|
|
49
|
+
|
|
50
|
+
# Route summary
|
|
51
|
+
parts << build_routes_section(client)
|
|
52
|
+
|
|
53
|
+
text = parts.compact.join("\n\n")
|
|
54
|
+
|
|
55
|
+
if text.include?("(unavailable)")
|
|
56
|
+
text += "\n\n#{RailsHelper::TRAP_CONTEXT_HINT}" if RailsHelper.trap_context?(client)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
60
|
+
rescue DebugMcp::Error => e
|
|
61
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def build_app_section(client)
|
|
67
|
+
lines = ["=== Rails Application ==="]
|
|
68
|
+
|
|
69
|
+
app_name = eval_expr(client, "Rails.application.class.module_parent_name")
|
|
70
|
+
lines << "App: #{app_name}" if app_name
|
|
71
|
+
|
|
72
|
+
rails_version = eval_expr(client, "Rails::VERSION::STRING")
|
|
73
|
+
rails_env = eval_expr(client, "Rails.env")
|
|
74
|
+
if rails_version
|
|
75
|
+
env_part = rails_env ? " (#{rails_env})" : ""
|
|
76
|
+
lines << "Rails: #{rails_version}#{env_part}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
ruby_version = eval_expr(client, "RUBY_VERSION")
|
|
80
|
+
lines << "Ruby: #{ruby_version}" if ruby_version
|
|
81
|
+
|
|
82
|
+
lines.join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_root_section(client)
|
|
86
|
+
root = eval_expr(client, "Rails.root.to_s")
|
|
87
|
+
root ? "Root: #{root}" : nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_db_section(client)
|
|
91
|
+
code = build_db_script
|
|
92
|
+
result = run_info_script(client, code)
|
|
93
|
+
# Fall through to YAML fallback if result is an error message
|
|
94
|
+
# (e.g., "Database:\n Error: can't be called from trap context")
|
|
95
|
+
return result if result && !result.include?("Error:")
|
|
96
|
+
|
|
97
|
+
# Fallback: read database.yml directly (works in trap context)
|
|
98
|
+
build_db_section_from_yaml(client)
|
|
99
|
+
rescue DebugMcp::Error
|
|
100
|
+
build_db_section_from_yaml(client)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_routes_section(client)
|
|
104
|
+
code = build_routes_script
|
|
105
|
+
result = run_info_script(client, code)
|
|
106
|
+
result || "Routes:\n (unavailable)"
|
|
107
|
+
rescue DebugMcp::Error
|
|
108
|
+
"Routes:\n (unavailable)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def run_info_script(client, code)
|
|
112
|
+
RailsHelper.run_base64_script(client, code)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def eval_expr(client, expr)
|
|
116
|
+
RailsHelper.eval_expr(client, expr)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fallback: read config/database.yml via the debug session's File.read.
|
|
120
|
+
# File I/O works in trap context (no threads/mutex needed).
|
|
121
|
+
# ERB tags are replaced with DYNAMIC since they can't be evaluated.
|
|
122
|
+
def build_db_section_from_yaml(client)
|
|
123
|
+
root = eval_expr(client, "Rails.root.to_s")
|
|
124
|
+
return "Database:\n (unavailable)" unless root
|
|
125
|
+
|
|
126
|
+
rails_env = eval_expr(client, "Rails.env") || "development"
|
|
127
|
+
yaml_path = "#{root}/config/database.yml"
|
|
128
|
+
# Use RailsHelper.eval_expr which properly handles \n unescaping
|
|
129
|
+
raw = RailsHelper.eval_expr(client, "File.read(#{yaml_path.inspect})")
|
|
130
|
+
return "Database:\n (unavailable)" unless raw
|
|
131
|
+
|
|
132
|
+
parse_database_yaml(raw, rails_env)
|
|
133
|
+
rescue StandardError
|
|
134
|
+
"Database:\n (unavailable)"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Parse database.yml content, stripping ERB and extracting the current env config.
|
|
138
|
+
def parse_database_yaml(raw_yaml, rails_env)
|
|
139
|
+
# Replace ERB tags with placeholder
|
|
140
|
+
sanitized = raw_yaml.gsub(/<%.*?%>/, "DYNAMIC")
|
|
141
|
+
config = YAML.safe_load(sanitized, permitted_classes: [Symbol]) || {}
|
|
142
|
+
env_config = config[rails_env] || config["default"] || config.values.first
|
|
143
|
+
|
|
144
|
+
return "Database:\n (unavailable)" unless env_config.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
lines = ["Database: (from database.yml)"]
|
|
147
|
+
env_config.each do |key, value|
|
|
148
|
+
key_s = key.to_s
|
|
149
|
+
val = SENSITIVE_KEYS.include?(key_s) ? "[FILTERED]" : value.to_s
|
|
150
|
+
lines << " #{key_s}: #{val}"
|
|
151
|
+
end
|
|
152
|
+
lines.join("\n")
|
|
153
|
+
rescue Psych::SyntaxError
|
|
154
|
+
"Database:\n (unavailable — database.yml parse error)"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Scripts return values instead of using puts.
|
|
158
|
+
# In trap context, puts output is not captured by the debug gem,
|
|
159
|
+
# but expression return values are always captured.
|
|
160
|
+
def build_db_script
|
|
161
|
+
sensitive_keys = SENSITIVE_KEYS.map { |k| "\"#{k}\"" }.join(", ")
|
|
162
|
+
<<~RUBY
|
|
163
|
+
begin
|
|
164
|
+
if defined?(ActiveRecord::Base)
|
|
165
|
+
config = ActiveRecord::Base.connection_db_config
|
|
166
|
+
hash = config.configuration_hash
|
|
167
|
+
sensitive = [#{sensitive_keys}]
|
|
168
|
+
lines = ["Database:"]
|
|
169
|
+
hash.each do |k, v|
|
|
170
|
+
key_s = k.to_s
|
|
171
|
+
val = sensitive.include?(key_s) ? "[FILTERED]" : v.to_s
|
|
172
|
+
lines << " " + key_s + ": " + val
|
|
173
|
+
end
|
|
174
|
+
lines.join("\\n")
|
|
175
|
+
end
|
|
176
|
+
rescue => e
|
|
177
|
+
"Database:\\n Error: " + e.message
|
|
178
|
+
end
|
|
179
|
+
RUBY
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_routes_script
|
|
183
|
+
<<~RUBY
|
|
184
|
+
begin
|
|
185
|
+
routes = Rails.application.routes.routes
|
|
186
|
+
count = routes.count { |r| !r.defaults[:controller].to_s.empty? }
|
|
187
|
+
if count > 0
|
|
188
|
+
"Routes: " + count.to_s + " defined (use 'rails_routes' for details)"
|
|
189
|
+
else
|
|
190
|
+
"Routes: none defined"
|
|
191
|
+
end
|
|
192
|
+
rescue => e
|
|
193
|
+
"Routes: unable to load"
|
|
194
|
+
end
|
|
195
|
+
RUBY
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|