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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "../rails_helper"
5
+
6
+ module DebugMcp
7
+ module Tools
8
+ class ListFiles < MCP::Tool
9
+ MAX_ENTRIES = 500
10
+
11
+ description "[Investigation] List files and directories in a path from the debug session's machine. " \
12
+ "Use this to explore directory structure, find source files, or locate configuration. " \
13
+ "Relative paths are resolved against the debugged process's working directory."
14
+
15
+ annotations(
16
+ title: "List Files",
17
+ read_only_hint: true,
18
+ destructive_hint: false,
19
+ open_world_hint: false,
20
+ )
21
+
22
+ input_schema(
23
+ properties: {
24
+ path: {
25
+ type: "string",
26
+ description: "Directory path to list (relative to working directory or absolute)",
27
+ },
28
+ pattern: {
29
+ type: "string",
30
+ description: "Optional glob pattern to filter entries (e.g., '*.rb', '**/*.yml'). " \
31
+ "When omitted, lists immediate children of the directory.",
32
+ },
33
+ session_id: {
34
+ type: "string",
35
+ description: "Debug session ID (uses default session if omitted)",
36
+ },
37
+ },
38
+ required: ["path"],
39
+ )
40
+
41
+ class << self
42
+ def call(path:, pattern: nil, session_id: nil, server_context:)
43
+ client = get_client(server_context, session_id)
44
+
45
+ if client&.remote
46
+ return list_remote(client, path, pattern)
47
+ end
48
+
49
+ full_path = resolve_path(path, server_context, session_id)
50
+ list_local(full_path, pattern)
51
+ rescue DebugMcp::Error => e
52
+ MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
53
+ end
54
+
55
+ private
56
+
57
+ def get_client(server_context, session_id)
58
+ if session_id
59
+ server_context[:session_manager].get(session_id).client
60
+ else
61
+ server_context[:session_manager].client
62
+ end
63
+ rescue DebugMcp::Error
64
+ nil
65
+ end
66
+
67
+ def resolve_path(path, server_context, session_id)
68
+ return File.expand_path(path) if path.start_with?("/") || path.start_with?("~")
69
+
70
+ cwd = remote_cwd(server_context, session_id)
71
+ if cwd
72
+ File.join(cwd, path)
73
+ else
74
+ File.expand_path(path)
75
+ end
76
+ end
77
+
78
+ def remote_cwd(server_context, session_id)
79
+ client = if session_id
80
+ server_context[:session_manager].get(session_id).client
81
+ else
82
+ server_context[:session_manager].client
83
+ end
84
+ client.auto_repause!
85
+ result = client.send_command("p Dir.pwd")
86
+ cleaned = result.strip.sub(/\A=> /, "")
87
+ return nil if cleaned == "nil" || cleaned.empty?
88
+
89
+ if cleaned.start_with?('"') && cleaned.end_with?('"')
90
+ cleaned = cleaned[1..-2]
91
+ end
92
+ cleaned.empty? ? nil : cleaned
93
+ rescue DebugMcp::Error
94
+ nil
95
+ end
96
+
97
+ def list_local(full_path, pattern)
98
+ unless Dir.exist?(full_path)
99
+ return MCP::Tool::Response.new([{ type: "text",
100
+ text: "Error: Directory not found: #{full_path}" }])
101
+ end
102
+
103
+ if pattern
104
+ glob_path = File.join(full_path, pattern)
105
+ entries = Dir.glob(glob_path).sort
106
+ else
107
+ entries = Dir.children(full_path).sort.map { |name| File.join(full_path, name) }
108
+ end
109
+
110
+ format_entries(full_path, entries, pattern)
111
+ end
112
+
113
+ def list_remote(client, path, pattern)
114
+ client.auto_repause!
115
+
116
+ # Resolve relative path
117
+ full_path = if path.start_with?("/")
118
+ path
119
+ else
120
+ cwd = RailsHelper.eval_expr(client, "Dir.pwd")
121
+ cwd ? File.join(cwd, path) : path
122
+ end
123
+
124
+ # Check directory exists
125
+ exists = RailsHelper.eval_expr(client, "Dir.exist?(#{full_path.inspect})")
126
+ unless exists == "true"
127
+ return MCP::Tool::Response.new([{ type: "text",
128
+ text: "Error: Directory not found on remote process: #{full_path}" }])
129
+ end
130
+
131
+ if pattern
132
+ expr = "Dir.glob(File.join(#{full_path.inspect}, #{pattern.inspect})).sort.first(#{MAX_ENTRIES + 1})" \
133
+ ".map{|f| (File.directory?(f) ? 'd:' : 'f:') + f }.join(\"\\n\")"
134
+ else
135
+ expr = "Dir.children(#{full_path.inspect}).sort.first(#{MAX_ENTRIES + 1})" \
136
+ ".map{|n| f=File.join(#{full_path.inspect},n); (File.directory?(f) ? 'd:' : 'f:') + f }.join(\"\\n\")"
137
+ end
138
+
139
+ result = RailsHelper.eval_expr(client, expr)
140
+ unless result
141
+ return MCP::Tool::Response.new([{ type: "text",
142
+ text: "Error: Failed to list directory on remote process: #{full_path}" }])
143
+ end
144
+
145
+ raw_entries = result.split("\n").reject(&:empty?)
146
+ truncated = raw_entries.length > MAX_ENTRIES
147
+ raw_entries = raw_entries.first(MAX_ENTRIES) if truncated
148
+
149
+ lines = []
150
+ raw_entries.each do |entry|
151
+ if entry.start_with?("d:")
152
+ lines << "[dir] #{entry[2..]}"
153
+ else
154
+ lines << "[file] #{entry[2..]}"
155
+ end
156
+ end
157
+
158
+ header = "#{full_path} (#{raw_entries.length} entries) [remote]"
159
+ header += " (truncated to #{MAX_ENTRIES})" if truncated
160
+ text = "#{header}\n\n#{lines.join("\n")}"
161
+
162
+ MCP::Tool::Response.new([{ type: "text", text: text }])
163
+ rescue DebugMcp::Error => e
164
+ MCP::Tool::Response.new([{ type: "text", text: "Error listing remote directory: #{e.message}" }])
165
+ end
166
+
167
+ def format_entries(full_path, entries, pattern)
168
+ truncated = entries.length > MAX_ENTRIES
169
+ entries = entries.first(MAX_ENTRIES) if truncated
170
+
171
+ lines = entries.map do |entry|
172
+ if File.directory?(entry)
173
+ "[dir] #{entry}"
174
+ else
175
+ "[file] #{entry}"
176
+ end
177
+ end
178
+
179
+ header = "#{full_path} (#{entries.length} entries)"
180
+ header += " matching '#{pattern}'" if pattern
181
+ header += " (truncated to #{MAX_ENTRIES})" if truncated
182
+ text = "#{header}\n\n#{lines.join("\n")}"
183
+
184
+ MCP::Tool::Response.new([{ type: "text", text: text }])
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module DebugMcp
6
+ module Tools
7
+ class ListPausedSessions < MCP::Tool
8
+ description "[Discovery] List all active debug sessions managed by debug-mcp. " \
9
+ "Shows connected sessions, their PIDs, and idle time."
10
+
11
+ annotations(
12
+ title: "List Active Sessions",
13
+ read_only_hint: true,
14
+ destructive_hint: false,
15
+ open_world_hint: false,
16
+ )
17
+
18
+ input_schema(
19
+ properties: {},
20
+ )
21
+
22
+ class << self
23
+ def call(server_context:)
24
+ manager = server_context[:session_manager]
25
+ sessions = manager.active_sessions(include_client: true)
26
+
27
+ if sessions.empty?
28
+ text = "No active debug sessions. Use 'connect' or 'run_script' to start one."
29
+ else
30
+ lines = sessions.map { |s| format_session(s) }
31
+ text = "Active debug sessions:\n#{lines.join("\n")}"
32
+ text += "\n\nNote: Sessions expire after inactivity. Any tool call resets the timer."
33
+ end
34
+
35
+ MCP::Tool::Response.new([{ type: "text", text: text }])
36
+ end
37
+
38
+ private
39
+
40
+ def format_session(s)
41
+ status = s[:connected] ? "connected" : "disconnected"
42
+ status += ", paused" if s[:paused]
43
+ idle = format_duration(s[:idle_seconds])
44
+
45
+ line = " #{s[:session_id]} (PID: #{s[:pid]}, #{status}, idle: #{idle})"
46
+
47
+ # Show remaining time before timeout
48
+ if s[:timeout_seconds]
49
+ remaining = s[:timeout_seconds] - s[:idle_seconds]
50
+ if remaining > 300
51
+ line += "\n Timeout: #{format_duration(remaining)} remaining"
52
+ elsif remaining > 0
53
+ line += "\n Timeout: #{format_duration(remaining)} remaining (WARNING: expiring soon)"
54
+ else
55
+ line += "\n Timeout: EXPIRED (will be reaped on next check)"
56
+ end
57
+ end
58
+
59
+ # Query current stop location and breakpoint count from the client
60
+ client = s[:client]
61
+ if client&.connected? && client.paused
62
+ location = query_stop_location(client)
63
+ line += "\n Location: #{location}" if location
64
+
65
+ bp_count = query_breakpoint_count(client)
66
+ line += "\n Breakpoints: #{bp_count}" if bp_count
67
+ end
68
+
69
+ line
70
+ end
71
+
72
+ # Query the current stop location from the debug session.
73
+ # Returns "file.rb:10 in ClassName#method" or nil.
74
+ def query_stop_location(client)
75
+ output = client.send_command("frame")
76
+ if (match = output.match(/#\d+\s+(.+?)\s+at\s+(.+:\d+)/))
77
+ "#{match[2]} in #{match[1].strip}"
78
+ end
79
+ rescue DebugMcp::Error
80
+ nil
81
+ end
82
+
83
+ # Query the number of breakpoints set.
84
+ # Returns a string like "3 set" or nil.
85
+ def query_breakpoint_count(client)
86
+ output = client.send_command("info breakpoints")
87
+ cleaned = output.strip
88
+ return nil if cleaned.empty? || cleaned.include?("No breakpoints")
89
+
90
+ count = cleaned.lines.count { |l| l.match?(/\A\s*#\d+/) }
91
+ count > 0 ? "#{count} set" : nil
92
+ rescue DebugMcp::Error
93
+ nil
94
+ end
95
+
96
+ def format_duration(seconds)
97
+ if seconds < 60
98
+ "#{seconds}s"
99
+ elsif seconds < 3600
100
+ "#{seconds / 60}m #{seconds % 60}s"
101
+ else
102
+ "#{seconds / 3600}h #{(seconds % 3600) / 60}m"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module DebugMcp
6
+ module Tools
7
+ class Next < MCP::Tool
8
+ description "[Control] Step over to the next line without entering method calls. " \
9
+ "Use 'step' instead to enter called methods. " \
10
+ "Inside a block (e.g., each, map, reject), 'next' advances within the current " \
11
+ "block iteration. To skip to the NEXT ITERATION, use set_breakpoint with one_shot: true " \
12
+ "on the first line of the block body, then continue_execution. " \
13
+ "Use 'finish' to exit the current block/method entirely. " \
14
+ "If an exception is raised and rescued during the step, it will be reported automatically."
15
+
16
+ annotations(
17
+ title: "Step Over",
18
+ read_only_hint: false,
19
+ destructive_hint: false,
20
+ open_world_hint: false,
21
+ )
22
+
23
+ input_schema(
24
+ properties: {
25
+ session_id: {
26
+ type: "string",
27
+ description: "Debug session ID (uses default session if omitted)",
28
+ },
29
+ },
30
+ )
31
+
32
+ class << self
33
+ def call(session_id: nil, server_context:)
34
+ client = server_context[:session_manager].client(session_id)
35
+
36
+ output = client.send_command("next")
37
+
38
+ if output.strip.empty? && client.process_finished?
39
+ text = DebugMcp::ExitMessageBuilder.build_exit_message(
40
+ "Program exited during step.", output, client,
41
+ )
42
+ return MCP::Tool::Response.new([{ type: "text", text: text }])
43
+ end
44
+
45
+ client.cleanup_one_shot_breakpoints(output)
46
+ output = DebugMcp::StopEventAnnotator.annotate_breakpoint_hit(output)
47
+ output = DebugMcp::StopEventAnnotator.enrich_stop_context(output, client)
48
+
49
+ MCP::Tool::Response.new([{ type: "text", text: output }])
50
+ rescue DebugMcp::SessionError => e
51
+ text = if e.message.include?("session ended") || e.message.include?("finished execution")
52
+ DebugMcp::ExitMessageBuilder.build_exit_message("Program exited during step.", e.final_output, client)
53
+ else
54
+ "Error: #{e.message}"
55
+ end
56
+ MCP::Tool::Response.new([{ type: "text", text: text }])
57
+ rescue DebugMcp::ConnectionError => e
58
+ text = if e.message.include?("Connection lost") || e.message.include?("connection closed")
59
+ DebugMcp::ExitMessageBuilder.build_exit_message("Program exited during step.", e.final_output, client)
60
+ else
61
+ "Error: #{e.message}"
62
+ end
63
+ MCP::Tool::Response.new([{ type: "text", text: text }])
64
+ rescue DebugMcp::Error => e
65
+ MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require "yaml"
5
+ require_relative "../rails_helper"
6
+
7
+ module DebugMcp
8
+ module Tools
9
+ class RailsInfo < MCP::Tool
10
+ description "[Investigation] Show Rails application overview: app name, Rails/Ruby versions, " \
11
+ "environment, and root path. Also shows database configuration and route count " \
12
+ "when available (these require escaping trap context first on Puma/threaded servers). " \
13
+ "Use this after connecting to a Rails process to quickly understand the application."
14
+
15
+ annotations(
16
+ title: "Rails App Info",
17
+ read_only_hint: true,
18
+ destructive_hint: false,
19
+ open_world_hint: false,
20
+ )
21
+
22
+ input_schema(
23
+ properties: {
24
+ session_id: {
25
+ type: "string",
26
+ description: "Debug session ID (uses default session if omitted)",
27
+ },
28
+ },
29
+ )
30
+
31
+ SENSITIVE_KEYS = %w[password secret secret_key_base secret_key].freeze
32
+
33
+ class << self
34
+ def call(session_id: nil, server_context:)
35
+ client = server_context[:session_manager].client(session_id)
36
+ client.auto_repause!
37
+ RailsHelper.require_rails!(client)
38
+
39
+ parts = []
40
+
41
+ # App name, Rails version, environment
42
+ parts << build_app_section(client)
43
+
44
+ # Root path
45
+ parts << build_root_section(client)
46
+
47
+ # Database configuration
48
+ parts << build_db_section(client)
49
+
50
+ # Route summary
51
+ parts << build_routes_section(client)
52
+
53
+ text = parts.compact.join("\n\n")
54
+
55
+ if text.include?("(unavailable)")
56
+ text += "\n\n#{RailsHelper::TRAP_CONTEXT_HINT}" if RailsHelper.trap_context?(client)
57
+ end
58
+
59
+ MCP::Tool::Response.new([{ type: "text", text: text }])
60
+ rescue DebugMcp::Error => e
61
+ MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
62
+ end
63
+
64
+ private
65
+
66
+ def build_app_section(client)
67
+ lines = ["=== Rails Application ==="]
68
+
69
+ app_name = eval_expr(client, "Rails.application.class.module_parent_name")
70
+ lines << "App: #{app_name}" if app_name
71
+
72
+ rails_version = eval_expr(client, "Rails::VERSION::STRING")
73
+ rails_env = eval_expr(client, "Rails.env")
74
+ if rails_version
75
+ env_part = rails_env ? " (#{rails_env})" : ""
76
+ lines << "Rails: #{rails_version}#{env_part}"
77
+ end
78
+
79
+ ruby_version = eval_expr(client, "RUBY_VERSION")
80
+ lines << "Ruby: #{ruby_version}" if ruby_version
81
+
82
+ lines.join("\n")
83
+ end
84
+
85
+ def build_root_section(client)
86
+ root = eval_expr(client, "Rails.root.to_s")
87
+ root ? "Root: #{root}" : nil
88
+ end
89
+
90
+ def build_db_section(client)
91
+ code = build_db_script
92
+ result = run_info_script(client, code)
93
+ # Fall through to YAML fallback if result is an error message
94
+ # (e.g., "Database:\n Error: can't be called from trap context")
95
+ return result if result && !result.include?("Error:")
96
+
97
+ # Fallback: read database.yml directly (works in trap context)
98
+ build_db_section_from_yaml(client)
99
+ rescue DebugMcp::Error
100
+ build_db_section_from_yaml(client)
101
+ end
102
+
103
+ def build_routes_section(client)
104
+ code = build_routes_script
105
+ result = run_info_script(client, code)
106
+ result || "Routes:\n (unavailable)"
107
+ rescue DebugMcp::Error
108
+ "Routes:\n (unavailable)"
109
+ end
110
+
111
+ def run_info_script(client, code)
112
+ RailsHelper.run_base64_script(client, code)
113
+ end
114
+
115
+ def eval_expr(client, expr)
116
+ RailsHelper.eval_expr(client, expr)
117
+ end
118
+
119
+ # Fallback: read config/database.yml via the debug session's File.read.
120
+ # File I/O works in trap context (no threads/mutex needed).
121
+ # ERB tags are replaced with DYNAMIC since they can't be evaluated.
122
+ def build_db_section_from_yaml(client)
123
+ root = eval_expr(client, "Rails.root.to_s")
124
+ return "Database:\n (unavailable)" unless root
125
+
126
+ rails_env = eval_expr(client, "Rails.env") || "development"
127
+ yaml_path = "#{root}/config/database.yml"
128
+ # Use RailsHelper.eval_expr which properly handles \n unescaping
129
+ raw = RailsHelper.eval_expr(client, "File.read(#{yaml_path.inspect})")
130
+ return "Database:\n (unavailable)" unless raw
131
+
132
+ parse_database_yaml(raw, rails_env)
133
+ rescue StandardError
134
+ "Database:\n (unavailable)"
135
+ end
136
+
137
+ # Parse database.yml content, stripping ERB and extracting the current env config.
138
+ def parse_database_yaml(raw_yaml, rails_env)
139
+ # Replace ERB tags with placeholder
140
+ sanitized = raw_yaml.gsub(/<%.*?%>/, "DYNAMIC")
141
+ config = YAML.safe_load(sanitized, permitted_classes: [Symbol]) || {}
142
+ env_config = config[rails_env] || config["default"] || config.values.first
143
+
144
+ return "Database:\n (unavailable)" unless env_config.is_a?(Hash)
145
+
146
+ lines = ["Database: (from database.yml)"]
147
+ env_config.each do |key, value|
148
+ key_s = key.to_s
149
+ val = SENSITIVE_KEYS.include?(key_s) ? "[FILTERED]" : value.to_s
150
+ lines << " #{key_s}: #{val}"
151
+ end
152
+ lines.join("\n")
153
+ rescue Psych::SyntaxError
154
+ "Database:\n (unavailable — database.yml parse error)"
155
+ end
156
+
157
+ # Scripts return values instead of using puts.
158
+ # In trap context, puts output is not captured by the debug gem,
159
+ # but expression return values are always captured.
160
+ def build_db_script
161
+ sensitive_keys = SENSITIVE_KEYS.map { |k| "\"#{k}\"" }.join(", ")
162
+ <<~RUBY
163
+ begin
164
+ if defined?(ActiveRecord::Base)
165
+ config = ActiveRecord::Base.connection_db_config
166
+ hash = config.configuration_hash
167
+ sensitive = [#{sensitive_keys}]
168
+ lines = ["Database:"]
169
+ hash.each do |k, v|
170
+ key_s = k.to_s
171
+ val = sensitive.include?(key_s) ? "[FILTERED]" : v.to_s
172
+ lines << " " + key_s + ": " + val
173
+ end
174
+ lines.join("\\n")
175
+ end
176
+ rescue => e
177
+ "Database:\\n Error: " + e.message
178
+ end
179
+ RUBY
180
+ end
181
+
182
+ def build_routes_script
183
+ <<~RUBY
184
+ begin
185
+ routes = Rails.application.routes.routes
186
+ count = routes.count { |r| !r.defaults[:controller].to_s.empty? }
187
+ if count > 0
188
+ "Routes: " + count.to_s + " defined (use 'rails_routes' for details)"
189
+ else
190
+ "Routes: none defined"
191
+ end
192
+ rescue => e
193
+ "Routes: unable to load"
194
+ end
195
+ RUBY
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end