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

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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