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,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