Module: DebugMcp::RailsHelper
- Defined in:
- lib/debug_mcp/rails_helper.rb
Constant Summary collapse
- TRAP_CONTEXT_HINT =
"Note: The process may be in signal trap context (common with Puma). " \ "Set a breakpoint and use trigger_request to escape trap context first."
Class Method Summary collapse
-
.clean_script_output(output) ⇒ Object
Clean debug gem output from a script that returns a string value.
-
.decode_json_result(output, default) ⇒ Object
Decode the result of a json_command from send_command output.
-
.eval_expr(client, expr) ⇒ Object
Evaluate a simple ‘p` expression and return the cleaned string result.
-
.json_command(json_string_expr) ⇒ Object
Wrap a target-side expression that evaluates to a JSON STRING so its value comes back as a base64 blob on the debug gem’s ‘=> <result>` line.
-
.lightweight_routes(client, controller: nil, path: nil, limit: 200) ⇒ Object
Fetch routes using a single ‘p` expression (trap-safe).
-
.log_file_path(client) ⇒ Object
Get the path to the Rails log file (trap-safe).
-
.model_files(client) ⇒ Object
List model files from app/models/ using Dir.glob (trap-safe).
-
.observability_probe(client) ⇒ Object
Probe Rails runtime observability settings (trap-safe; uses eval_expr).
- .probe_value(client, expr) ⇒ Object
-
.rails?(client) ⇒ Boolean
Check if Rails is available without raising.
-
.require_rails!(client) ⇒ Object
Verify that the connected process is a Rails application.
-
.route_summary(client, limit: 5) ⇒ Object
Fetch a compact route summary for connect output (trap-safe).
-
.run_base64_script(client, code, timeout: 15) ⇒ Object
Execute a multi-line Ruby script via Base64 encoding in the target process.
-
.trap_context?(client) ⇒ Boolean
Check if the client is in signal trap context.
Class Method Details
.clean_script_output(output) ⇒ Object
Clean debug gem output from a script that returns a string value. Strips “=> ” prefix, removes surrounding quotes, and unescapes \n.
51 52 53 54 55 56 57 58 59 |
# File 'lib/debug_mcp/rails_helper.rb', line 51 def clean_script_output(output) cleaned = output.strip.sub(/\A=> /, "") return nil if cleaned == "nil" || cleaned.empty? if cleaned.start_with?('"') && cleaned.end_with?('"') cleaned = cleaned[1..-2].gsub('\\n', "\n").gsub('\\"', '"') end cleaned.empty? ? nil : cleaned end |
.decode_json_result(output, default) ⇒ Object
Decode the result of a json_command from send_command output. The debug gem echoes the evaluated value as a quoted string, e.g. ‘“<base64>”` (sometimes with a `=> ` prefix, and — for long values — WRAPPED across several lines at the debugger’s width, which read_until_input joins with newlines).
The value is a base64 blob, whose alphabet contains no ‘“`, so we take everything between the first and last double-quote and strip whitespace (Base64.decode64 also ignores embedded newlines). Returns `default` on any failure (not installed, parse error, timeout-truncated output, …).
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/debug_mcp/rails_helper.rb', line 209 def decode_json_result(output, default) return default unless output first = output.index('"') last = output.rindex('"') return default unless first && last && last > first b64 = output[(first + 1)...last].gsub(/\s/, "") return default if b64.empty? # strict_decode64 (vs decode64) rejects a truncated/corrupt blob instead of # silently returning partial bytes, so we fall back to `default` cleanly. JSON.parse(Base64.strict_decode64(b64), symbolize_names: true) rescue StandardError default end |
.eval_expr(client, expr) ⇒ Object
Evaluate a simple ‘p` expression and return the cleaned string result. Uses `p` (not `puts`) because `p` output is captured as the expression result by the debug gem, which works even in signal trap context. Returns nil if the result is nil or evaluation fails.
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/debug_mcp/rails_helper.rb', line 67 def eval_expr(client, expr) # Note: internal probes use simple expressions (Rails.root, Rails.env, # Dir.pwd, etc.) that don't fire ActiveSupport::Notifications events, # so SourceTagging.wrap isn't applied here. Tools that take arbitrary # user expressions (evaluate_code, inspect_object) handle tagging # themselves at the call site. result = client.send_command("p #{expr}") cleaned = result.strip.sub(/\A=> /, "") return nil if cleaned == "nil" || cleaned.empty? if cleaned.start_with?('"') && cleaned.end_with?('"') cleaned = cleaned[1..-2] cleaned = cleaned.gsub('\\n', "\n").gsub('\\"', '"').gsub("\\\\", "\\") end cleaned.empty? ? nil : cleaned rescue DebugMcp::Error nil end |
.json_command(json_string_expr) ⇒ Object
Wrap a target-side expression that evaluates to a JSON STRING so its value comes back as a base64 blob on the debug gem’s ‘=> <result>` line.
Why base64: send_command only returns the evaluated expression’s inspected value; the debuggee’s own stdout (anything printed with puts/p) is NOT forwarded over the debug socket. So we cannot rely on ‘puts(x.to_json)` emitting a parseable line — its output goes to the target’s stdout and send_command just sees ‘=> nil`. Returning the JSON directly would work but then it is wrapped in Ruby string-inspect escaping (" , \ , n) that is fragile to undo. Base64’s alphabet contains none of those, so it round-trips through inspect/quoting untouched.
196 197 198 |
# File 'lib/debug_mcp/rails_helper.rb', line 196 def json_command(json_string_expr) "[(#{json_string_expr})].pack(\"m0\")" end |
.lightweight_routes(client, controller: nil, path: nil, limit: 200) ⇒ Object
Fetch routes using a single ‘p` expression (trap-safe). Returns { count: Integer, lines: String } or nil on failure.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/debug_mcp/rails_helper.rb', line 88 def lightweight_routes(client, controller: nil, path: nil, limit: 200) filter_parts = ["r.defaults[:controller].to_s!=''"] filter_parts << "r.defaults[:controller].to_s.include?(#{controller.inspect})" if controller filter_parts << "r.path.spec.to_s.include?(#{path.inspect})" if path filter = filter_parts.join(" && ") count_output = eval_expr(client, "Rails.application.routes.routes.count{|r|r.defaults[:controller].to_s!=''}") return nil if count_output.nil? # eval failed — can't access routes count = count_output.to_i expr = "Rails.application.routes.routes.select{|r|#{filter}}." \ "first(#{limit}).map{|r|" \ "r.verb.to_s.ljust(7)+' '+" \ "r.path.spec.to_s.sub('(.:format)','')+' '+" \ "r.defaults[:controller].to_s+'#'+r.defaults[:action].to_s+" \ "(r.name.to_s.empty? ? '' : ' ('+r.name.to_s+')')}.join(\"\\n\")" lines = eval_expr(client, expr) { count: count, lines: lines || "" } rescue DebugMcp::Error nil end |
.log_file_path(client) ⇒ Object
Get the path to the Rails log file (trap-safe). Returns the absolute path string or nil if not determinable.
175 176 177 178 179 180 181 182 183 |
# File 'lib/debug_mcp/rails_helper.rb', line 175 def log_file_path(client) root = eval_expr(client, "Rails.root.to_s") env = eval_expr(client, "Rails.env") return nil unless root && env "#{root}/log/#{env}.log" rescue DebugMcp::Error nil end |
.model_files(client) ⇒ Object
List model files from app/models/ using Dir.glob (trap-safe). Returns array of model file names (e.g., [“user”, “post”, “admin/account”]) or nil.
136 137 138 139 140 141 142 143 144 145 |
# File 'lib/debug_mcp/rails_helper.rb', line 136 def model_files(client) output = eval_expr(client, "Dir.glob(Rails.root.join('app','models','**','*.rb').to_s)." \ "sort.map{|f|f.split('/models/').last.sub('.rb','')}.reject{|f|f=='application_record'}.join(', ')") return nil if output.nil? || output.empty? output.split(", ") rescue DebugMcp::Error nil end |
.observability_probe(client) ⇒ Object
Probe Rails runtime observability settings (trap-safe; uses eval_expr). Returns a hash of human-readable string values, falling back to “(unavailable)” when a probe can’t be evaluated (e.g. trap context).
These are plain attribute/class reads (delivery_method, queue adapter name, cache store class) that do NOT fire ActiveSupport::Notifications events, so no debug_eval source tagging is needed — consistent with eval_expr’s contract. Shared by rails_info and the mail/recent-events tools so they report the same observability preconditions.
156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/debug_mcp/rails_helper.rb', line 156 def observability_probe(client) { delivery_method: probe_value(client, "(defined?(ActionMailer::Base) ? ActionMailer::Base.delivery_method : :no_action_mailer)"), queue_adapter: probe_value(client, "(defined?(ActiveJob::Base) ? " \ "(ActiveJob::Base.queue_adapter_name rescue ActiveJob::Base.queue_adapter.class.name) : " \ ":no_active_job)"), cache_store: probe_value(client, "((defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache) ? Rails.cache.class.name : '(none)')"), } end |
.probe_value(client, expr) ⇒ Object
169 170 171 |
# File 'lib/debug_mcp/rails_helper.rb', line 169 def probe_value(client, expr) eval_expr(client, expr) || "(unavailable)" end |
.rails?(client) ⇒ Boolean
Check if Rails is available without raising. Returns true if the connected process has Rails loaded.
22 23 24 25 26 27 |
# File 'lib/debug_mcp/rails_helper.rb', line 22 def rails?(client) result = client.send_command("p defined?(Rails)") result.strip.sub(/\A=> /, "").include?("constant") rescue DebugMcp::Error false end |
.require_rails!(client) ⇒ Object
Verify that the connected process is a Rails application. Raises SessionError if Rails is not defined.
13 14 15 16 17 18 |
# File 'lib/debug_mcp/rails_helper.rb', line 13 def require_rails!(client) result = client.send_command("p defined?(Rails)") unless result.strip.sub(/\A=> /, "").include?("constant") raise DebugMcp::SessionError, "Not a Rails application. This tool requires a connected Rails process." end end |
.route_summary(client, limit: 5) ⇒ Object
Fetch a compact route summary for connect output (trap-safe). Returns { count: Integer, samples: [String] } or nil.
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/debug_mcp/rails_helper.rb', line 115 def route_summary(client, limit: 5) count_output = eval_expr(client, "Rails.application.routes.routes.count{|r|r.defaults[:controller].to_s!=''}") return nil if count_output.nil? count = count_output.to_i sample_expr = "Rails.application.routes.routes.select{|r|r.defaults[:controller].to_s!=''}." \ "first(#{limit}).map{|r|" \ "r.verb.to_s.ljust(7)+' '+" \ "r.path.spec.to_s.sub('(.:format)','')+' '+" \ "r.defaults[:controller].to_s+'#'+r.defaults[:action].to_s}.join(\"\\n\")" samples = eval_expr(client, sample_expr) { count: count, samples: samples&.split("\n") || [] } rescue DebugMcp::Error nil end |
.run_base64_script(client, code, timeout: 15) ⇒ Object
Execute a multi-line Ruby script via Base64 encoding in the target process. Returns the cleaned string result, or nil if the script returned nil/empty. Raises DebugMcp::Error on communication failure.
42 43 44 45 46 47 |
# File 'lib/debug_mcp/rails_helper.rb', line 42 def run_base64_script(client, code, timeout: 15) encoded = Base64.strict_encode64(code.encode(Encoding::UTF_8)) command = "require 'base64'; eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8'))" output = client.send_command(command, timeout: timeout) clean_script_output(output) end |
.trap_context?(client) ⇒ Boolean
Check if the client is in signal trap context. Returns true if thread operations are restricted.
31 32 33 34 35 |
# File 'lib/debug_mcp/rails_helper.rb', line 31 def trap_context?(client) client.respond_to?(:in_trap_context?) && client.in_trap_context? rescue DebugMcp::Error false end |