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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugMcp
4
+ module ExitMessageBuilder
5
+ module_function
6
+
7
+ # Build a detailed exit message with exception detection.
8
+ # Parses stderr and debugger output to determine whether the program
9
+ # exited normally or due to an unhandled exception.
10
+ def build_exit_message(header, final_output, client)
11
+ # Wait for the process to fully exit so all output is flushed to files.
12
+ # wait_thread is set by run_script; nil for connect sessions.
13
+ exit_status = wait_for_process(client)
14
+
15
+ stderr = client&.read_stderr_output
16
+ stdout = client&.read_stdout_output
17
+
18
+ # Try to detect exception from stderr first, then fall back to debugger output
19
+ exception_info = detect_exception(stderr) || detect_exception(final_output)
20
+
21
+ parts = []
22
+
23
+ # Build a clear header with exit status
24
+ if exit_status
25
+ if exit_status.success?
26
+ parts << "#{header}\nExit status: 0 (success)"
27
+ elsif exit_status.signaled?
28
+ parts << "#{header}\nKilled by signal #{exit_status.termsig}"
29
+ else
30
+ parts << "#{header}\nExit status: #{exit_status.exitstatus} (error)"
31
+ end
32
+ else
33
+ parts << header
34
+ end
35
+
36
+ if exception_info
37
+ parts << "Unhandled exception: #{exception_info}"
38
+ end
39
+
40
+ parts << "Debugger output:\n#{final_output}" if final_output
41
+ parts << "Program output (stdout):\n#{stdout}" if stdout
42
+ parts << "Process stderr:\n#{stderr}" if stderr
43
+
44
+ if stdout.nil? && stderr.nil?
45
+ # Connect session: no captured output, guide toward run_script
46
+ tip = "stdout/stderr are not captured for sessions started with 'connect'."
47
+ if exception_info
48
+ tip += "\nCheck the terminal where the debug process was started for the full stack trace."
49
+ else
50
+ tip += "\nThe program may have exited due to an unhandled exception — " \
51
+ "check the terminal where the debug process was started for details."
52
+ end
53
+ tip += "\n\nTo get better diagnostics next time:\n" \
54
+ " - Use 'run_script' instead of 'connect' to capture stdout/stderr automatically\n" \
55
+ " - Use set_breakpoint(exception_class: 'NoMethodError') to stop BEFORE " \
56
+ "an exception crashes the process"
57
+ parts << tip
58
+ else
59
+ # run_script session: session is over, guide toward restart
60
+ tip = "This debug session has ended."
61
+ rerun_hint = build_rerun_hint(client)
62
+ if exception_info
63
+ exc_class = exception_info.split(":").first
64
+ tip += "\n\nTo debug the crash:\n" \
65
+ " 1. #{rerun_hint}\n" \
66
+ " 2. set_breakpoint(exception_class: '#{exc_class}') to catch the exception before it crashes"
67
+ else
68
+ tip += "\n\nTo restart: #{rerun_hint}"
69
+ end
70
+ parts << tip
71
+ end
72
+
73
+ parts.join("\n\n")
74
+ end
75
+
76
+ # Wait for the spawned process to exit (up to 5 seconds).
77
+ # Returns Process::Status or nil.
78
+ def wait_for_process(client)
79
+ return nil unless client&.wait_thread
80
+
81
+ client.wait_thread.join(5)
82
+ client.wait_thread.value
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ # Build a concrete run_script hint with the exact file/args from the session.
88
+ def build_rerun_hint(client)
89
+ script_file = client&.script_file
90
+ return "run_script(file: '...', restore_breakpoints: true)" unless script_file
91
+
92
+ args_part = client.script_args&.any? ? ", args: #{client.script_args.inspect}" : ""
93
+ "run_script(file: '#{script_file}'#{args_part}, restore_breakpoints: true)"
94
+ end
95
+
96
+ # Detect Ruby exception from output text.
97
+ # Returns "ExceptionClass: message" string, or nil if no exception found.
98
+ def detect_exception(output)
99
+ return nil unless output && !output.empty?
100
+
101
+ # Ruby stack trace format:
102
+ # /path/to/file.rb:10:in `method': message (ExceptionClass)
103
+ if output =~ /:\d+:in `.+': (.+) \((\w+(?:::\w+)*)\)/
104
+ "#{$2}: #{$1}"
105
+ # Alternative format (e.g., from raise without stack trace context):
106
+ # ExceptionClass: message
107
+ elsif output =~ /\A\s*((?:\w+::)*\w+(?:Error|Exception)): (.+)/
108
+ "#{$1}: #{$2.strip}"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugMcp
4
+ module PendingHttpHelper
5
+ module_function
6
+
7
+ # Check for pending HTTP request status and return a note string.
8
+ # Returns nil when there is no pending HTTP or when it is still running (normal state).
9
+ def pending_http_note(client)
10
+ pending = client.pending_http
11
+ return nil unless pending
12
+
13
+ holder = pending[:holder]
14
+ return nil unless holder[:done]
15
+
16
+ if holder[:error]
17
+ "Note: HTTP request (#{pending[:method]} #{pending[:url]}) failed: #{holder[:error].message}. " \
18
+ "Use 'continue_execution' to resume."
19
+ elsif holder[:response]
20
+ "Note: HTTP response received (#{holder[:response][:status]}). " \
21
+ "Use 'continue_execution' to see the full response."
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module DebugMcp
6
+ module RailsHelper
7
+ module_function
8
+
9
+ # Verify that the connected process is a Rails application.
10
+ # Raises SessionError if Rails is not defined.
11
+ def require_rails!(client)
12
+ result = client.send_command("p defined?(Rails)")
13
+ unless result.strip.sub(/\A=> /, "").include?("constant")
14
+ raise DebugMcp::SessionError, "Not a Rails application. This tool requires a connected Rails process."
15
+ end
16
+ end
17
+
18
+ # Check if Rails is available without raising.
19
+ # Returns true if the connected process has Rails loaded.
20
+ def rails?(client)
21
+ result = client.send_command("p defined?(Rails)")
22
+ result.strip.sub(/\A=> /, "").include?("constant")
23
+ rescue DebugMcp::Error
24
+ false
25
+ end
26
+
27
+ # Check if the client is in signal trap context.
28
+ # Returns true if thread operations are restricted.
29
+ def trap_context?(client)
30
+ client.respond_to?(:in_trap_context?) && client.in_trap_context?
31
+ rescue DebugMcp::Error
32
+ false
33
+ end
34
+
35
+ # --- Base64 script execution (NOT trap-safe) ---
36
+
37
+ # Execute a multi-line Ruby script via Base64 encoding in the target process.
38
+ # Returns the cleaned string result, or nil if the script returned nil/empty.
39
+ # Raises DebugMcp::Error on communication failure.
40
+ def run_base64_script(client, code, timeout: 15)
41
+ encoded = Base64.strict_encode64(code.encode(Encoding::UTF_8))
42
+ command = "require 'base64'; eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8'))"
43
+ output = client.send_command(command, timeout: timeout)
44
+ clean_script_output(output)
45
+ end
46
+
47
+ # Clean debug gem output from a script that returns a string value.
48
+ # Strips "=> " prefix, removes surrounding quotes, and unescapes \\n.
49
+ def clean_script_output(output)
50
+ cleaned = output.strip.sub(/\A=> /, "")
51
+ return nil if cleaned == "nil" || cleaned.empty?
52
+
53
+ if cleaned.start_with?('"') && cleaned.end_with?('"')
54
+ cleaned = cleaned[1..-2].gsub('\\n', "\n").gsub('\\"', '"')
55
+ end
56
+ cleaned.empty? ? nil : cleaned
57
+ end
58
+
59
+ # --- Lightweight methods (trap-safe, no Base64/require/puts) ---
60
+
61
+ # Evaluate a simple `p` expression and return the cleaned string result.
62
+ # Uses `p` (not `puts`) because `p` output is captured as the expression
63
+ # result by the debug gem, which works even in signal trap context.
64
+ # Returns nil if the result is nil or evaluation fails.
65
+ def eval_expr(client, expr)
66
+ result = client.send_command("p #{expr}")
67
+ cleaned = result.strip.sub(/\A=> /, "")
68
+ return nil if cleaned == "nil" || cleaned.empty?
69
+
70
+ if cleaned.start_with?('"') && cleaned.end_with?('"')
71
+ cleaned = cleaned[1..-2]
72
+ cleaned = cleaned.gsub('\\n', "\n").gsub('\\"', '"').gsub("\\\\", "\\")
73
+ end
74
+ cleaned.empty? ? nil : cleaned
75
+ rescue DebugMcp::Error
76
+ nil
77
+ end
78
+
79
+ # Fetch routes using a single `p` expression (trap-safe).
80
+ # Returns { count: Integer, lines: String } or nil on failure.
81
+ def lightweight_routes(client, controller: nil, path: nil, limit: 200)
82
+ filter_parts = ["r.defaults[:controller].to_s!=''"]
83
+ filter_parts << "r.defaults[:controller].to_s.include?(#{controller.inspect})" if controller
84
+ filter_parts << "r.path.spec.to_s.include?(#{path.inspect})" if path
85
+ filter = filter_parts.join(" && ")
86
+
87
+ count_output = eval_expr(client,
88
+ "Rails.application.routes.routes.count{|r|r.defaults[:controller].to_s!=''}")
89
+ return nil if count_output.nil? # eval failed — can't access routes
90
+
91
+ count = count_output.to_i
92
+
93
+ expr = "Rails.application.routes.routes.select{|r|#{filter}}." \
94
+ "first(#{limit}).map{|r|" \
95
+ "r.verb.to_s.ljust(7)+' '+" \
96
+ "r.path.spec.to_s.sub('(.:format)','')+' '+" \
97
+ "r.defaults[:controller].to_s+'#'+r.defaults[:action].to_s+" \
98
+ "(r.name.to_s.empty? ? '' : ' ('+r.name.to_s+')')}.join(\"\\n\")"
99
+ lines = eval_expr(client, expr)
100
+
101
+ { count: count, lines: lines || "" }
102
+ rescue DebugMcp::Error
103
+ nil
104
+ end
105
+
106
+ # Fetch a compact route summary for connect output (trap-safe).
107
+ # Returns { count: Integer, samples: [String] } or nil.
108
+ def route_summary(client, limit: 5)
109
+ count_output = eval_expr(client,
110
+ "Rails.application.routes.routes.count{|r|r.defaults[:controller].to_s!=''}")
111
+ return nil if count_output.nil?
112
+
113
+ count = count_output.to_i
114
+
115
+ sample_expr = "Rails.application.routes.routes.select{|r|r.defaults[:controller].to_s!=''}." \
116
+ "first(#{limit}).map{|r|" \
117
+ "r.verb.to_s.ljust(7)+' '+" \
118
+ "r.path.spec.to_s.sub('(.:format)','')+' '+" \
119
+ "r.defaults[:controller].to_s+'#'+r.defaults[:action].to_s}.join(\"\\n\")"
120
+ samples = eval_expr(client, sample_expr)
121
+
122
+ { count: count, samples: samples&.split("\n") || [] }
123
+ rescue DebugMcp::Error
124
+ nil
125
+ end
126
+
127
+ # List model files from app/models/ using Dir.glob (trap-safe).
128
+ # Returns array of model file names (e.g., ["user", "post", "admin/account"]) or nil.
129
+ def model_files(client)
130
+ output = eval_expr(client,
131
+ "Dir.glob(Rails.root.join('app','models','**','*.rb').to_s)." \
132
+ "sort.map{|f|f.split('/models/').last.sub('.rb','')}.reject{|f|f=='application_record'}.join(', ')")
133
+ return nil if output.nil? || output.empty?
134
+
135
+ output.split(", ")
136
+ rescue DebugMcp::Error
137
+ nil
138
+ end
139
+
140
+ # Get the path to the Rails log file (trap-safe).
141
+ # Returns the absolute path string or nil if not determinable.
142
+ def log_file_path(client)
143
+ root = eval_expr(client, "Rails.root.to_s")
144
+ env = eval_expr(client, "Rails.env")
145
+ return nil unless root && env
146
+
147
+ "#{root}/log/#{env}.log"
148
+ rescue DebugMcp::Error
149
+ nil
150
+ end
151
+
152
+ TRAP_CONTEXT_HINT = "Note: The process may be in signal trap context (common with Puma). " \
153
+ "Set a breakpoint and use trigger_request to escape trap context first."
154
+ end
155
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "tools/list_debug_sessions"
5
+ require_relative "tools/connect"
6
+ require_relative "tools/evaluate_code"
7
+ require_relative "tools/inspect_object"
8
+ require_relative "tools/get_context"
9
+ require_relative "tools/get_source"
10
+ require_relative "tools/read_file"
11
+ require_relative "tools/list_files"
12
+ require_relative "tools/continue_execution"
13
+ require_relative "tools/set_breakpoint"
14
+ require_relative "tools/remove_breakpoint"
15
+ require_relative "tools/step"
16
+ require_relative "tools/next"
17
+ require_relative "tools/finish"
18
+ require_relative "tools/run_debug_command"
19
+ require_relative "tools/run_script"
20
+ require_relative "tools/trigger_request"
21
+ require_relative "tools/list_paused_sessions"
22
+ require_relative "tools/disconnect"
23
+ require_relative "tools/rails_info"
24
+ require_relative "tools/rails_routes"
25
+ require_relative "tools/rails_model"
26
+
27
+ module DebugMcp
28
+ class Server
29
+ # Base tools: always available
30
+ BASE_TOOLS = [
31
+ # Discovery & connection
32
+ Tools::ListDebugSessions,
33
+ Tools::Connect,
34
+ Tools::ListPausedSessions,
35
+ # Investigation
36
+ Tools::EvaluateCode,
37
+ Tools::InspectObject,
38
+ Tools::GetContext,
39
+ Tools::GetSource,
40
+ Tools::ReadFile,
41
+ Tools::ListFiles,
42
+ # Control
43
+ Tools::SetBreakpoint,
44
+ Tools::RemoveBreakpoint,
45
+ Tools::ContinueExecution,
46
+ Tools::Step,
47
+ Tools::Next,
48
+ Tools::Finish,
49
+ Tools::RunDebugCommand,
50
+ Tools::Disconnect,
51
+ # Entry points
52
+ Tools::RunScript,
53
+ Tools::TriggerRequest,
54
+ ].freeze
55
+
56
+ # Rails tools: dynamically added when a Rails process is detected
57
+ RAILS_TOOLS = [
58
+ Tools::RailsInfo,
59
+ Tools::RailsRoutes,
60
+ Tools::RailsModel,
61
+ ].freeze
62
+
63
+ # All tools (used in tests and for reference)
64
+ TOOLS = (BASE_TOOLS + RAILS_TOOLS).freeze
65
+
66
+ DEFAULT_HTTP_PORT = 6029
67
+ DEFAULT_HTTP_HOST = "127.0.0.1"
68
+
69
+ INSTRUCTIONS = <<~TEXT
70
+ debug-mcp is an MCP server that connects LLM agents to Ruby's debug gem. \
71
+ It lets you attach to live Ruby processes, inspect variables, evaluate code, \
72
+ set breakpoints, and control execution.
73
+
74
+ Use these tools when the user asks to debug a Ruby program, investigate runtime behavior, \
75
+ or inspect the state of a running process.
76
+
77
+ Typical workflow:
78
+ 1. run_script to launch a Ruby script under the debugger (recommended — captures stdout/stderr). \
79
+ Use connect only when attaching to an already-running process (e.g., Rails server).
80
+ 2. get_context to see the current state (variables, call stack, breakpoints)
81
+ 3. evaluate_code / inspect_object to investigate specific values
82
+ 4. set_breakpoint / next / step / continue_execution to control the flow
83
+
84
+ When to use get_context:
85
+ - After connecting or run_script — to understand the initial stop point
86
+ - After continue_execution hits a breakpoint — the stop output shows source and stack, \
87
+ but get_context gives you local/instance variables and the full breakpoint list
88
+ - When you need to check what breakpoints are currently set
89
+ - When variables or call stack context would help decide the next debugging action
90
+ - You do NOT need get_context after every next/step if the output already shows \
91
+ the information you need (source listing and stop location are included in the response)
92
+ - For a quick breakpoint check without fetching all context, use \
93
+ run_debug_command(command: "info breakpoints")
94
+
95
+ IMPORTANT — connect pauses the target process:
96
+ When you use 'connect', the target process is PAUSED. It will not serve requests or \
97
+ respond to Ctrl+C until you resume it. Always use 'continue_execution' when done \
98
+ investigating, or 'disconnect' to detach (which also resumes the process). \
99
+ Never leave a connected session idle without resuming — the user won't be able to \
100
+ interact with the target process.
101
+
102
+ Signal trap context (Puma/threaded servers):
103
+ When connecting to a process like Puma, the debug gem pauses it via SIGURG. \
104
+ This puts the process in a signal trap context where thread operations (Mutex, \
105
+ DB connection pools, autoloading) fail with ThreadError. \
106
+ Simple expressions (variables, constants, p/pp) still work in trap context. \
107
+ The 'connect' tool automatically detects and tries to escape this. \
108
+ Additionally, after 'continue_execution', investigation tools (evaluate_code, \
109
+ get_context, etc.) automatically re-pause and re-escape trap context — \
110
+ you do not need to manually set breakpoints again to escape. \
111
+ If auto-escape fails (common when the process is blocked on IO like IO.select): \
112
+ 1. set_breakpoint on a line in your controller/action \
113
+ 2. trigger_request to send an HTTP request — this auto-resumes the process \
114
+ 3. Once stopped at the breakpoint, all operations work normally \
115
+ Do NOT manually call continue_execution before trigger_request — \
116
+ trigger_request handles resuming the process automatically.
117
+
118
+ Rails debugging:
119
+ When you connect to a Rails process, additional Rails-specific tools become available \
120
+ automatically (rails_info, rails_routes, rails_model). These tools are NOT shown \
121
+ when debugging plain Ruby scripts.
122
+
123
+ Rails debugging workflow:
124
+ 1. Start the Rails server with debugging: RUBY_DEBUG_OPEN=true bin/rails server
125
+ 2. connect to attach to the Rails process (auto-detects trap context)
126
+ 3. set_breakpoint on a controller action (e.g., app/controllers/users_controller.rb:10)
127
+ 4. trigger_request to send an HTTP request — this auto-resumes the paused process, \
128
+ sends the request, and waits for the breakpoint to hit. \
129
+ CSRF protection is automatically disabled for non-GET requests. \
130
+ You do NOT need to call continue_execution first.
131
+ 5. When the breakpoint hits, use get_context, evaluate_code, and rails_model to \
132
+ inspect the current state and understand model structures
133
+ 6. continue_execution to let the request complete and see the response
134
+ 7. To debug another request, set new breakpoints and call trigger_request again
135
+ 8. When done debugging, use 'disconnect' to detach and resume the server
136
+
137
+ Note: rails_info, rails_routes, and rails_model may not work in trap context. \
138
+ Use them after hitting a breakpoint via trigger_request.
139
+
140
+ Docker / containerized processes:
141
+ When the debug target runs inside a Docker container, use connect with a TCP port \
142
+ or a Unix socket volume mount. \
143
+ TCP: connect(port: 12345) — works out of the box. \
144
+ Unix socket: connect(path: "/shared/rdbg.sock", remote: true) — you MUST pass \
145
+ remote: true because the socket file is local but the process is in a different \
146
+ PID namespace, so OS signals cannot reach it. Without remote: true, pause/resume \
147
+ will fail silently.
148
+
149
+ Security — proper use of evaluate_code:
150
+ evaluate_code is designed EXCLUSIVELY for investigating the runtime state of the debugged \
151
+ process (inspecting variables, checking object state, testing expressions in context). \
152
+ It must NOT be used as a general-purpose code execution tool.
153
+
154
+ PROHIBITED uses of evaluate_code:
155
+ - File I/O: File.write, File.delete, FileUtils, IO.write \
156
+ → Use your agent's own file tools (Read, Write, Edit) instead
157
+ - System commands: system(), exec(), backtick, %x{}, Open3, spawn \
158
+ → Use your agent's own Bash/shell tool instead
159
+ - Network requests: Net::HTTP, open-uri, TCPSocket, HTTP client gems \
160
+ → Use your agent's own HTTP/network tools instead
161
+ - Process manipulation: Process.kill, fork, exit, abort
162
+ - Destructive data operations: destroy_all, delete_all, DROP/TRUNCATE SQL
163
+
164
+ IMPORTANT: If your agent's tools are restricted for a particular operation, \
165
+ you must NOT use evaluate_code to circumvent those restrictions.
166
+
167
+ Quick tool selection guide:
168
+ - "Where am I? What are the variables and breakpoints?" → get_context
169
+ - "Execute a Ruby expression or test a fix" → evaluate_code
170
+ - "See an object's full structure (class, ivars, value)" → inspect_object
171
+ - "Read the source of a method or class" → get_source
172
+ - "Read a file from the debugged process's machine" → read_file
173
+ - "List files or explore directory structure" → list_files
174
+ - "Step to the next line (stay in current method)" → next
175
+ - "Step into a method call" → step
176
+ - "Run until current method/block returns" → finish
177
+ - "Resume until next breakpoint" → continue_execution
178
+ - "Send an HTTP request and wait for breakpoint" → trigger_request
179
+
180
+ Breakpoints in blocks/loops (each, map, select, etc.):
181
+ Line breakpoints inside a block fire on EVERY iteration. If you only need to stop once, \
182
+ use one_shot: true when setting the breakpoint — it auto-removes after the first hit.
183
+
184
+ Typical pattern for Rails debugging:
185
+ set_breakpoint → trigger_request → get_context → evaluate_code → continue_execution
186
+ TEXT
187
+
188
+ # Register Rails tools on an MCP server instance and notify connected clients.
189
+ # Safe to call multiple times — skips already-registered tools.
190
+ def self.register_rails_tools(mcp_server)
191
+ tools_hash = mcp_server.instance_variable_get(:@tools)
192
+ tool_names = mcp_server.instance_variable_get(:@tool_names)
193
+ added = false
194
+
195
+ RAILS_TOOLS.each do |tool_class|
196
+ name = tool_class.name_value
197
+ next if tools_hash.key?(name)
198
+
199
+ tools_hash[name] = tool_class
200
+ tool_names << name
201
+ added = true
202
+ end
203
+
204
+ mcp_server.notify_tools_list_changed if added
205
+ added
206
+ end
207
+
208
+ def initialize(transport: nil, port: nil, host: nil, session_timeout: nil, **_)
209
+ @transport_type = transport || "stdio"
210
+ @http_port = port || DEFAULT_HTTP_PORT
211
+ @http_host = host || DEFAULT_HTTP_HOST
212
+ @session_manager = SessionManager.new(
213
+ **(session_timeout ? { timeout: session_timeout } : {}),
214
+ )
215
+ end
216
+
217
+ def start
218
+ server_context = { session_manager: @session_manager }
219
+
220
+ server = MCP::Server.new(
221
+ name: "debug-mcp",
222
+ version: DebugMcp::VERSION,
223
+ instructions: INSTRUCTIONS,
224
+ tools: TOOLS,
225
+ server_context: server_context,
226
+ )
227
+
228
+ # Safety net: resume connected processes when the server exits for any reason.
229
+ # This covers cases where Claude Code exits without calling 'disconnect',
230
+ # stdin closes unexpectedly, or the MCP gem calls Kernel.exit directly.
231
+ # disconnect_all is idempotent, so multiple calls (at_exit + ensure + signal) are safe.
232
+ at_exit { @session_manager.disconnect_all }
233
+
234
+ setup_signal_handlers
235
+
236
+ case @transport_type
237
+ when "stdio"
238
+ start_stdio(server)
239
+ when "http"
240
+ start_http(server)
241
+ else
242
+ raise ArgumentError, "Unknown transport: #{@transport_type}"
243
+ end
244
+ ensure
245
+ @session_manager.disconnect_all
246
+ end
247
+
248
+ private
249
+
250
+ def start_stdio(server)
251
+ transport = MCP::Server::Transports::StdioTransport.new(server)
252
+ transport.open
253
+ end
254
+
255
+ def start_http(server)
256
+ require "webrick"
257
+
258
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
259
+
260
+ webrick = WEBrick::HTTPServer.new(
261
+ Port: @http_port,
262
+ BindAddress: @http_host,
263
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
264
+ AccessLog: [],
265
+ )
266
+
267
+ webrick.mount_proc("/mcp") do |req, res|
268
+ env = build_rack_env(req)
269
+ rack_request = RackRequestAdapter.new(env)
270
+ status, headers, body = transport.handle_request(rack_request)
271
+
272
+ res.status = status
273
+ headers.each { |k, v| res[k] = v }
274
+
275
+ if body.respond_to?(:each)
276
+ output = +""
277
+ body.each { |chunk| output << chunk }
278
+ res.body = output
279
+ elsif body.respond_to?(:call)
280
+ # SSE streaming body
281
+ rd, wr = IO.pipe
282
+ res["Content-Type"] = headers["Content-Type"] || "text/event-stream"
283
+ res["Cache-Control"] = "no-cache"
284
+ res["Connection"] = "keep-alive"
285
+ res.body = rd
286
+
287
+ Thread.new do
288
+ body.call(wr)
289
+ rescue IOError, Errno::EPIPE
290
+ # Client disconnected
291
+ ensure
292
+ wr.close unless wr.closed?
293
+ end
294
+ end
295
+ end
296
+
297
+ $stderr.puts "debug-mcp HTTP server listening on http://#{@http_host}:#{@http_port}/mcp"
298
+
299
+ setup_http_signal_handlers(webrick)
300
+ webrick.start
301
+ ensure
302
+ transport&.close
303
+ end
304
+
305
+ # Minimal Rack::Request-compatible adapter for WEBrick
306
+ class RackRequestAdapter
307
+ attr_reader :env
308
+
309
+ def initialize(env)
310
+ @env = env
311
+ end
312
+
313
+ def body
314
+ @env["rack.input"]
315
+ end
316
+ end
317
+
318
+ def build_rack_env(req)
319
+ env = {
320
+ "REQUEST_METHOD" => req.request_method,
321
+ "PATH_INFO" => req.path,
322
+ "QUERY_STRING" => req.query_string || "",
323
+ "SERVER_NAME" => @http_host,
324
+ "SERVER_PORT" => @http_port.to_s,
325
+ "rack.input" => StringIO.new(req.body || ""),
326
+ }
327
+
328
+ # Map HTTP headers to Rack convention
329
+ req.header.each do |key, values|
330
+ rack_key = "HTTP_#{key.tr("-", "_").upcase}"
331
+ env[rack_key] = values.join(", ")
332
+ end
333
+
334
+ # Ensure key headers are mapped correctly
335
+ env["CONTENT_TYPE"] = req.content_type if req.content_type
336
+ env["HTTP_ACCEPT"] = req["Accept"] if req["Accept"]
337
+ env["HTTP_MCP_SESSION_ID"] = req["Mcp-Session-Id"] if req["Mcp-Session-Id"]
338
+
339
+ env
340
+ end
341
+
342
+ def setup_signal_handlers
343
+ %w[INT TERM HUP].each do |sig|
344
+ trap(sig) do
345
+ @session_manager.disconnect_all
346
+ exit(0)
347
+ end
348
+ rescue ArgumentError
349
+ # Signal not supported on this platform (e.g., HUP on Windows)
350
+ end
351
+ end
352
+
353
+ def setup_http_signal_handlers(webrick)
354
+ %w[INT TERM HUP].each do |sig|
355
+ trap(sig) do
356
+ @session_manager.disconnect_all
357
+ webrick.shutdown
358
+ end
359
+ rescue ArgumentError
360
+ # Signal not supported on this platform
361
+ end
362
+ end
363
+ end
364
+ end