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,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
module TcpSessionDiscovery
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Discover TCP debug sessions from Docker containers and local processes.
|
|
11
|
+
# Returns array of { host:, port:, name:, source: } hashes.
|
|
12
|
+
def discover
|
|
13
|
+
(docker_sessions + local_tcp_sessions).uniq { |s| [s[:host], s[:port]] }
|
|
14
|
+
rescue StandardError
|
|
15
|
+
[]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Discover debug sessions from Docker containers with RUBY_DEBUG_PORT.
|
|
19
|
+
def docker_sessions
|
|
20
|
+
return [] unless docker_available?
|
|
21
|
+
|
|
22
|
+
container_ids = `docker ps -q 2>/dev/null`.strip.split("\n")
|
|
23
|
+
return [] if container_ids.empty?
|
|
24
|
+
|
|
25
|
+
sessions = []
|
|
26
|
+
container_ids.each do |id|
|
|
27
|
+
session = inspect_container(id)
|
|
28
|
+
sessions << session if session
|
|
29
|
+
end
|
|
30
|
+
sessions
|
|
31
|
+
rescue StandardError
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Discover debug sessions from local processes with RUBY_DEBUG_PORT in /proc/*/environ.
|
|
36
|
+
def local_tcp_sessions
|
|
37
|
+
return [] unless File.directory?("/proc")
|
|
38
|
+
|
|
39
|
+
sessions = []
|
|
40
|
+
Dir.glob("/proc/[0-9]*/environ").each do |environ_path|
|
|
41
|
+
session = inspect_local_process(environ_path)
|
|
42
|
+
sessions << session if session
|
|
43
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
sessions
|
|
47
|
+
rescue StandardError
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if a TCP host:port is connectable.
|
|
52
|
+
def tcp_connectable?(host, port, timeout: 2)
|
|
53
|
+
addr = Socket.getaddrinfo(host, nil, nil, :STREAM)
|
|
54
|
+
sockaddr = Socket.sockaddr_in(port, addr[0][3])
|
|
55
|
+
socket = Socket.new(Socket.const_get(addr[0][0]), Socket::SOCK_STREAM, 0)
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
socket.connect_nonblock(sockaddr)
|
|
59
|
+
rescue IO::WaitWritable
|
|
60
|
+
IO.select(nil, [socket], nil, timeout) ? socket.connect_nonblock(sockaddr) : (return false)
|
|
61
|
+
rescue Errno::EISCONN
|
|
62
|
+
# Already connected — success
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
true
|
|
66
|
+
rescue StandardError
|
|
67
|
+
false
|
|
68
|
+
ensure
|
|
69
|
+
socket&.close
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Find web server host ports for a Docker container identified by its debug port.
|
|
73
|
+
# Reverse-looks up which container has RUBY_DEBUG_PORT matching debug_port,
|
|
74
|
+
# then returns all other host-mapped ports that are TCP-connectable.
|
|
75
|
+
# Returns an array of port numbers (e.g., [3000]) or [] if not found.
|
|
76
|
+
def container_web_ports(debug_port, host: "localhost")
|
|
77
|
+
return [] unless docker_available?
|
|
78
|
+
|
|
79
|
+
container_ids = `docker ps -q 2>/dev/null`.strip.split("\n")
|
|
80
|
+
container_ids.each do |id|
|
|
81
|
+
json_str = `docker inspect #{id} 2>/dev/null`
|
|
82
|
+
data = JSON.parse(json_str)
|
|
83
|
+
container = data[0]
|
|
84
|
+
next unless container
|
|
85
|
+
|
|
86
|
+
# Check if this container has the matching debug port
|
|
87
|
+
env_list = container.dig("Config", "Env") || []
|
|
88
|
+
container_debug_port = env_list
|
|
89
|
+
.find { |e| e.start_with?("RUBY_DEBUG_PORT=") }
|
|
90
|
+
&.split("=", 2)&.last&.to_i
|
|
91
|
+
next unless container_debug_port == debug_port
|
|
92
|
+
|
|
93
|
+
# Extract all host-mapped ports except the debug port itself
|
|
94
|
+
port_bindings = container.dig("HostConfig", "PortBindings") || {}
|
|
95
|
+
web_ports = []
|
|
96
|
+
port_bindings.each do |key, bindings|
|
|
97
|
+
container_port = key.split("/").first.to_i
|
|
98
|
+
next if container_port == debug_port
|
|
99
|
+
next unless bindings.is_a?(Array) && !bindings.empty?
|
|
100
|
+
|
|
101
|
+
host_port = bindings[0]["HostPort"]&.to_i
|
|
102
|
+
next unless host_port&.positive?
|
|
103
|
+
next unless tcp_connectable?(host, host_port, timeout: 1)
|
|
104
|
+
|
|
105
|
+
web_ports << host_port
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return web_ports.sort
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[]
|
|
112
|
+
rescue StandardError
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# --- Private helpers ---
|
|
117
|
+
|
|
118
|
+
def docker_available?
|
|
119
|
+
system("docker", "info", out: File::NULL, err: File::NULL)
|
|
120
|
+
rescue StandardError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def inspect_container(container_id)
|
|
125
|
+
json_str = `docker inspect #{container_id} 2>/dev/null`
|
|
126
|
+
return nil if json_str.strip.empty?
|
|
127
|
+
|
|
128
|
+
data = JSON.parse(json_str)
|
|
129
|
+
container = data[0]
|
|
130
|
+
return nil unless container
|
|
131
|
+
|
|
132
|
+
env_list = container.dig("Config", "Env") || []
|
|
133
|
+
debug_port = nil
|
|
134
|
+
debug_host = nil
|
|
135
|
+
|
|
136
|
+
env_list.each do |env|
|
|
137
|
+
key, value = env.split("=", 2)
|
|
138
|
+
case key
|
|
139
|
+
when "RUBY_DEBUG_PORT"
|
|
140
|
+
debug_port = value&.to_i
|
|
141
|
+
when "RUBY_DEBUG_HOST"
|
|
142
|
+
debug_host = value
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
return nil unless debug_port
|
|
146
|
+
|
|
147
|
+
host_port = resolve_host_port(container, debug_port)
|
|
148
|
+
return nil unless host_port
|
|
149
|
+
|
|
150
|
+
host, port = host_port
|
|
151
|
+
return nil unless tcp_connectable?(host, port)
|
|
152
|
+
|
|
153
|
+
name = container.fetch("Name", "").sub(%r{\A/}, "")
|
|
154
|
+
name = container_id[0, 12] if name.empty?
|
|
155
|
+
|
|
156
|
+
{ host: host, port: port, name: name, source: :docker }
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def resolve_host_port(container, container_port)
|
|
162
|
+
port_bindings = container.dig("HostConfig", "PortBindings") || {}
|
|
163
|
+
|
|
164
|
+
# Look for a binding matching the debug port (e.g., "12345/tcp")
|
|
165
|
+
binding_key = port_bindings.keys.find { |k| k.start_with?("#{container_port}/") }
|
|
166
|
+
if binding_key
|
|
167
|
+
bindings = port_bindings[binding_key]
|
|
168
|
+
if bindings.is_a?(Array) && !bindings.empty?
|
|
169
|
+
host_port = bindings[0]["HostPort"]&.to_i
|
|
170
|
+
return ["localhost", host_port] if host_port && host_port > 0
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Fallback: NetworkSettings — use container IP directly
|
|
175
|
+
networks = container.dig("NetworkSettings", "Networks") || {}
|
|
176
|
+
networks.each_value do |net|
|
|
177
|
+
ip = net["IPAddress"]
|
|
178
|
+
return [ip, container_port] if ip && !ip.empty?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def inspect_local_process(environ_path)
|
|
185
|
+
environ = File.read(environ_path)
|
|
186
|
+
envs = environ.split("\0")
|
|
187
|
+
|
|
188
|
+
debug_port = nil
|
|
189
|
+
envs.each do |env|
|
|
190
|
+
key, value = env.split("=", 2)
|
|
191
|
+
if key == "RUBY_DEBUG_PORT"
|
|
192
|
+
debug_port = value&.to_i
|
|
193
|
+
break
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
return nil unless debug_port
|
|
197
|
+
|
|
198
|
+
pid = environ_path[%r{/proc/(\d+)/}, 1]
|
|
199
|
+
return nil unless pid
|
|
200
|
+
|
|
201
|
+
# Skip if this process is ourselves
|
|
202
|
+
return nil if pid.to_i == Process.pid
|
|
203
|
+
|
|
204
|
+
host = "localhost"
|
|
205
|
+
return nil unless tcp_connectable?(host, debug_port)
|
|
206
|
+
|
|
207
|
+
name = process_name(pid)
|
|
208
|
+
{ host: host, port: debug_port, name: name, source: :local }
|
|
209
|
+
rescue StandardError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def process_name(pid)
|
|
214
|
+
cmdline = File.read("/proc/#{pid}/cmdline").split("\0")
|
|
215
|
+
# Find the Ruby script name from the command line
|
|
216
|
+
ruby_idx = cmdline.index { |arg| arg.match?(/ruby|rails|rdbg|bundle/) }
|
|
217
|
+
if ruby_idx && cmdline[ruby_idx + 1]
|
|
218
|
+
File.basename(cmdline[ruby_idx + 1])
|
|
219
|
+
else
|
|
220
|
+
"pid-#{pid}"
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError
|
|
223
|
+
"pid-#{pid}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|