rails-ai-context 5.32.2 → 5.34.0

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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -9,6 +9,22 @@ module RailsAiContext
9
9
  class Server
10
10
  attr_reader :app, :transport_type
11
11
 
12
+ STOP_SIGNALS = %w[TERM INT].freeze
13
+
14
+ LOOPBACK = %w[127.0.0.1 ::1 localhost].freeze
15
+
16
+ # Seconds a stopping server gives requests still in flight. Puma's
17
+ # default is to wait for them forever, so one request that never
18
+ # finished - a tool stuck on a lock, a client that stopped reading - kept
19
+ # a stopped server running until SIGKILL. Puma then gives the request a
20
+ # further grace period of its own before it kills the thread, so a stop
21
+ # ends within about eight seconds.
22
+ SHUTDOWN_TIMEOUT = 3
23
+
24
+ # Past this many seconds after a stop signal the process exits whatever
25
+ # is still running: the stop above has long had its chance by then.
26
+ STOP_DEADLINE = 15
27
+
12
28
  # All built-in tools, auto-discovered from Tools::BaseTool subclasses.
13
29
  # Kept as a class method (not a constant) so auto-registration works.
14
30
  # Legacy constant accessor preserved for backwards compatibility.
@@ -84,62 +100,47 @@ module RailsAiContext
84
100
  end
85
101
  end
86
102
 
87
- # Build and return the configured MCP::Server instance
88
- def build
89
- config = RailsAiContext.configuration
90
-
91
- validated_custom_tools = self.class.resolve_custom_tools(config)
92
-
93
- mcp_config = MCP::Configuration.new(
94
- # Anything that still escapes a tool (schema validation bugs, SDK-level
95
- # failures) gets a stderr backtrace instead of vanishing into a bare
96
- # JSON-RPC internal error. Routine protocol-level errors (unknown
97
- # tool, invalid params) are expected traffic, not bugs - the mcp gem
98
- # already turns them into a proper JSON-RPC error response, so here
99
- # they get one quiet line instead of a scary 10-line backtrace.
100
- exception_reporter: lambda { |exception, _server_context|
101
- if exception.is_a?(MCP::Server::RequestHandlerError) && exception.error_type != :internal_error
102
- $stderr.puts "[rails-ai-context] request error (#{exception.error_type}): #{exception.message}"
103
- else
104
- $stderr.puts "[rails-ai-context] unhandled exception: #{exception.class}: #{exception.message}"
105
- Array(exception.backtrace).first(10).each { |line| $stderr.puts " #{line}" }
106
- end
107
- },
108
- instrumentation_callback: Instrumentation.callback
109
- )
110
-
111
- server = MCP::Server.new(
112
- name: config.server_name,
113
- version: config.server_version,
114
- instructions: "Ground truth engine for Rails apps. Live Prism AST introspection. Zero stale data.",
115
- tools: merge_tools(active_tools(config), validated_custom_tools),
116
- resource_templates: Resources.resource_templates,
117
- configuration: mcp_config
118
- )
103
+ # The built-in tools skip_tools turned off.
104
+ def self.skipped_tools(config = RailsAiContext.configuration)
105
+ config.skip_tools & builtin_tools.map(&:tool_name)
106
+ end
119
107
 
120
- Resources.register(server)
108
+ # One answer, on every surface, for a call naming a tool skip_tools
109
+ # turned off: it is out of every list, and "Unknown tool" sent the
110
+ # reader looking for a tool the gem has.
111
+ def self.skipped_tool_message(tool_name)
112
+ "#{tool_name} is turned off in this app: skip_tools lists it " \
113
+ "(.rails-ai-context.yml or config/initializers/rails_ai_context.rb). Take it out of skip_tools to use it."
114
+ end
121
115
 
122
- server
116
+ # The SDK's "Tool not found" named only the guess. The nearest tools
117
+ # follow it, or every tool when none is near.
118
+ def self.unknown_tool_message(tool_name, names)
119
+ near = Tools::BaseTool.find_closest_matches(tool_name.to_s, names) - [ tool_name.to_s ]
120
+ head = "Tool not found: #{Tools::BaseTool.echo_input(tool_name)}."
121
+ near.any? ? "#{head} Did you mean #{near.first(3).join(', ')}?" : "#{head} The tools are: #{names.sort.join(', ')}."
123
122
  end
124
123
 
125
- # Start the MCP server with the configured transport
126
- def start
127
- server = build
124
+ # Where the SDK carries a code and data on a handler's error, the
125
+ # message stays the error's own (not "Invalid params") and the data
126
+ # lists every tool, as a resource read lists the names it has.
127
+ def self.unknown_tool_data(names)
128
+ parameters = MCP::Server::RequestHandlerError.instance_method(:initialize).parameters
129
+ return {} unless parameters.any? { |_, name| name == :error_data }
128
130
 
129
- case transport_type
130
- when :stdio
131
- start_stdio(server)
132
- when :http, :streamable_http
133
- start_http(server)
134
- else
135
- raise ConfigurationError, "Unknown transport: #{transport_type}. Use :stdio or :http"
136
- end
131
+ { error_code: -32602, error_data: { available: names.sort } }
137
132
  end
138
133
 
139
- private
134
+ # What tools/list answers with: the built-ins skip_tools leaves, then
135
+ # each custom tool whose name no tool before it claims. The generated
136
+ # context files count and list this set, so they name no tool the
137
+ # server lacks and leave out none it serves.
138
+ def self.exposed_tools(config = RailsAiContext.configuration)
139
+ merge_tools(active_tools(config), resolve_custom_tools(config), warn: false)
140
+ end
140
141
 
141
- def active_tools(config)
142
- tools = self.class.builtin_tools
142
+ def self.active_tools(config)
143
+ tools = builtin_tools
143
144
  skip = config.skip_tools
144
145
  return tools if skip.empty?
145
146
 
@@ -154,7 +155,7 @@ module RailsAiContext
154
155
  # `inherited` and enrols it in the registry active_tools reads.
155
156
  # - Two different classes claiming one name. Deliberate replacement is
156
157
  # spelled with skip_tools; without it, keep the built-in and say so.
157
- def merge_tools(builtin, custom)
158
+ def self.merge_tools(builtin, custom, warn: true)
158
159
  merged = (builtin + custom).uniq
159
160
  builtin_names = builtin.to_set { |t| tool_label(t) }
160
161
 
@@ -169,34 +170,159 @@ module RailsAiContext
169
170
  else
170
171
  "keeping the first. Give one of them a different tool_name."
171
172
  end
172
- $stderr.puts "[rails-ai-context] WARNING: #{tools.size} tools claim the name #{name.inspect}; #{advice}"
173
+ $stderr.puts "[rails-ai-context] WARNING: #{tools.size} tools claim the name #{name.inspect}; #{advice}" if warn
173
174
  [ tools.first ]
174
175
  end
175
176
  end
176
177
 
177
178
  # MCP::Tool subclasses answer tool_name; anything else falls back to the
178
179
  # class name, which is what the SDK would key on anyway.
179
- def tool_label(tool)
180
+ def self.tool_label(tool)
180
181
  tool.respond_to?(:tool_name) ? tool.tool_name : tool.name
181
182
  end
182
183
 
184
+ # The name the server gives the client: config.server_name, and when
185
+ # that is the default, RAILS_AI_CONTEXT_SERVER_NAME in its place. A
186
+ # workspace's entries set the variable so each server announces its app
187
+ # first, since VS Code names every tool after this name and keeps 13
188
+ # characters of it; a name the app configured itself still wins.
189
+ def self.announced_name(config = RailsAiContext.configuration)
190
+ name = ENV[McpConfigGenerator::SERVER_NAME_ENV].to_s.strip
191
+ return config.server_name if name.empty? || config.server_name != McpConfigGenerator::SERVER_NAME
192
+
193
+ name
194
+ end
195
+
196
+ # What the client is told about freshness. An app that cannot reload
197
+ # keeps what reflection read at boot, and its answers say so once app
198
+ # code changed (BaseTool.stale_code_note).
199
+ def self.instructions
200
+ intro = "Ground truth engine for Rails apps. Live Prism AST introspection."
201
+ return "#{intro} Answers follow edits to the app's files." if CodeReloader.reloadable? || RailsAiContext.static_tier?
202
+
203
+ "#{intro} Answers follow edits to the app's files, except what reflection reads, such as associations: " \
204
+ "RAILS_ENV=#{RailsAiContext.environment_name} does not reload code, so that stays as of boot, and every " \
205
+ "answer says when app code changed since."
206
+ end
207
+
208
+ # Anything that still escapes a tool (schema validation bugs, SDK-level
209
+ # failures) is named on stderr instead of vanishing into a bare JSON-RPC
210
+ # internal error, in one line; the backtrace is DEBUG's, as it is for a
211
+ # boot failure. Routine protocol-level errors (unknown tool, invalid
212
+ # params) are expected traffic, not bugs - the mcp gem already turns them
213
+ # into a proper JSON-RPC error response, so here they get one quiet line.
214
+ EXCEPTION_REPORTER = lambda { |exception, _server_context|
215
+ if exception.is_a?(MCP::Server::RequestHandlerError) && exception.error_type != :internal_error
216
+ $stderr.puts "[rails-ai-context] request error (#{exception.error_type}): #{exception.message}"
217
+ elsif ENV["DEBUG"]
218
+ $stderr.puts "[rails-ai-context] unhandled exception: #{exception.class}: #{exception.message}"
219
+ Array(exception.backtrace).first(10).each { |line| $stderr.puts " #{line}" }
220
+ else
221
+ $stderr.puts "[rails-ai-context] unhandled exception: #{exception.class}: " \
222
+ "#{exception.message.to_s.lines.first&.strip} (DEBUG=1 prints the backtrace)"
223
+ end
224
+ }
225
+
226
+ # Build and return the configured MCP::Server instance
227
+ def build
228
+ config = RailsAiContext.configuration
229
+
230
+ validated_custom_tools = self.class.resolve_custom_tools(config)
231
+
232
+ mcp_config = MCP::Configuration.new(
233
+ exception_reporter: EXCEPTION_REPORTER,
234
+ instrumentation_callback: Instrumentation.callback
235
+ )
236
+
237
+ server = SdkServer.new(
238
+ skipped_tools: self.class.skipped_tools(config),
239
+ name: self.class.announced_name(config),
240
+ version: config.server_version,
241
+ instructions: self.class.instructions,
242
+ tools: self.class.merge_tools(self.class.active_tools(config), validated_custom_tools),
243
+ resource_templates: Resources.resource_templates,
244
+ configuration: mcp_config
245
+ )
246
+
247
+ Resources.register(server)
248
+
249
+ server
250
+ end
251
+
252
+ # Start the MCP server with the configured transport
253
+ def start
254
+ server = build
255
+ report_transport_errors
256
+
257
+ case transport_type
258
+ when :stdio
259
+ start_stdio(server)
260
+ when :http, :streamable_http
261
+ start_http(server)
262
+ else
263
+ raise ConfigurationError, "Unknown transport: #{transport_type}. Use :stdio or :http"
264
+ end
265
+ end
266
+
267
+ private
268
+
269
+ # The SDK's transports report through MCP.configuration's reporter, not
270
+ # the server's, so a stdio frame past the size limit ended the server
271
+ # with nothing on stderr. This process serves only this server, so the
272
+ # process-wide reporter is this one too, unless the app set its own.
273
+ def report_transport_errors
274
+ configuration = MCP.configuration
275
+ return unless configuration.respond_to?(:exception_reporter=)
276
+ return if configuration.respond_to?(:exception_reporter?) && configuration.exception_reporter?
277
+
278
+ configuration.exception_reporter = EXCEPTION_REPORTER
279
+ end
280
+
183
281
  # Read the list off the server rather than rebuilding it. Recomputing it
184
282
  # from the registry drops any custom tool that is not a BaseTool, so the
185
283
  # banner announced a different set than the server answered with.
186
284
  def tool_banner(server)
187
- names = server.tools.values.map { |t| tool_label(t) }.sort
285
+ names = server.tools.values.map { |t| self.class.tool_label(t) }.sort
188
286
  "[rails-ai-context] Tools (#{names.size}): #{names.join(', ')}"
189
287
  end
190
288
 
289
+ # The SDK's server, told which tools skip_tools turned off. They are out
290
+ # of tools/list, but a client can still name one - a context file written
291
+ # before the skip, a model's guess - and the SDK answered "Tool not
292
+ # found", a protocol error most clients never show the model.
293
+ class SdkServer < MCP::Server
294
+ def initialize(skipped_tools: [], **options)
295
+ @skipped_tools = skipped_tools
296
+ super(**options)
297
+ end
298
+
299
+ private
300
+
301
+ def call_tool(request, ...)
302
+ name = request[:name]
303
+ return super if tools.key?(name)
304
+ return MCP::Tool::Response.new([ { type: "text", text: Server.skipped_tool_message(name) } ], error: true).to_h if @skipped_tools.include?(name)
305
+
306
+ add_instrumentation_data(tool_name: name, error: :tool_not_found) if respond_to?(:add_instrumentation_data, true)
307
+ raise MCP::Server::RequestHandlerError.new(Server.unknown_tool_message(name, tools.keys), request,
308
+ error_type: :invalid_params, **Server.unknown_tool_data(tools.keys))
309
+ end
310
+ end
311
+
191
312
  # Writes to the saved channel, since the session points $stdout and fd 1
192
313
  # at stderr so nothing a tool prints reaches the JSON-RPC stream.
193
314
  class StdioChannelTransport < MCP::Server::Transports::StdioTransport
315
+ # The frame past the SDK's size limit that ended the connection, if one did.
316
+ attr_reader :frame_error
317
+
194
318
  # The SDK sets UTF-8 on $stdout, which is $stderr here; it goes on the channel instead.
195
319
  def initialize(server, channel)
196
320
  stderr_encoding = [ $stdout.external_encoding, $stdout.internal_encoding ]
197
321
  super(server)
198
322
  $stdout.set_encoding(*stderr_encoding)
199
323
  @channel = channel
324
+ # A frame ends in a newline alone; Windows writes one as CRLF in text mode.
325
+ @channel.binmode
200
326
  @channel.set_encoding("UTF-8")
201
327
  end
202
328
 
@@ -204,6 +330,20 @@ module RailsAiContext
204
330
  @channel.puts(message.is_a?(String) ? message : JSON.generate(message))
205
331
  @channel.flush
206
332
  end
333
+
334
+ private
335
+
336
+ # The SDK ends the connection when a frame passes its size limit (4 MiB)
337
+ # without a newline, and the server exited 0 with the client's request
338
+ # unanswered. The client gets a JSON-RPC error first, and the failure
339
+ # is kept for start_stdio to end on.
340
+ def read_line(io)
341
+ super
342
+ rescue MCP::Server::RequestHandlerError => e
343
+ @frame_error = e
344
+ send_response({ jsonrpc: "2.0", id: nil, error: { code: McpEdge::INVALID_REQUEST, message: "Invalid Request: #{e.message}" } })
345
+ raise
346
+ end
207
347
  end
208
348
 
209
349
  def start_stdio(server)
@@ -214,6 +354,7 @@ module RailsAiContext
214
354
  RailsAiContext.stdio_open = true
215
355
  maybe_start_live_reload(server)
216
356
  transport.open
357
+ raise RailsAiContext::Error, "MCP stdio connection closed: #{transport.frame_error.message}" if transport.frame_error
217
358
  end
218
359
  ensure
219
360
  RailsAiContext.stdio_open = false
@@ -226,18 +367,102 @@ module RailsAiContext
226
367
  # Build a minimal Rack app that delegates to the MCP transport
227
368
  rack_app = build_rack_app(transport)
228
369
 
229
- loopback = %w[127.0.0.1 ::1 localhost].freeze
230
- unless loopback.include?(config.http_bind)
231
- $stderr.puts "[rails-ai-context] WARNING: MCP HTTP transport binding to #{config.http_bind} - " \
232
- "this exposes all tools to the network without authentication. " \
233
- "Use 127.0.0.1 (default) unless you have external auth in place."
234
- end
370
+ $stderr.puts bind_warning(config.http_bind) unless LOOPBACK.include?(config.http_bind)
235
371
  $stderr.puts "[rails-ai-context] MCP server starting on #{config.http_bind}:#{config.http_port}#{config.http_path}"
236
372
  $stderr.puts tool_banner(server)
237
373
  maybe_start_live_reload(server)
238
374
 
239
375
  handler = default_rack_handler
240
- handler.run(rack_app, **rack_handler_options(handler, config))
376
+ handler.run(rack_app, **rack_handler_options(handler, config)) do |launcher|
377
+ stop_outside_signal_handler(launcher) if puma_handler?(handler)
378
+ end
379
+ rescue SignalException => e
380
+ # Puma stops gracefully on SIGTERM and then raises it, so a stop that
381
+ # had already finished reached rake as "bin/rails aborted!
382
+ # SignalException: SIGTERM". A stop signal ends the server, quietly.
383
+ raise unless STOP_SIGNALS.include?(Signal.signame(e.signo))
384
+ ensure
385
+ stop_http(transport)
386
+ end
387
+
388
+ # Puma runs a SIGTERM stop inside the signal handler: it writes to its
389
+ # wake-up pipe there and then joins the server thread, which closes that
390
+ # pipe on its way out. When the close catches the handler's write still
391
+ # registered on the pipe, Ruby makes the closer wait for the writer to
392
+ # let go, which needs a mutex code in a signal handler may not take, so
393
+ # neither thread moved again: the server printed "Gracefully stopping"
394
+ # and hung until SIGKILL, and a second signal did nothing because signal
395
+ # handlers do not nest. Here a stop signal only queues the stop, and an
396
+ # ordinary thread asks Puma for it - the non-blocking stop its own
397
+ # SIGINT handler uses - then ends the process if the stop never does.
398
+ def stop_outside_signal_handler(launcher)
399
+ stops = Thread::Queue.new
400
+ booted = Thread::Queue.new
401
+ trap_stops = -> { STOP_SIGNALS.each { |name| Signal.trap(name) { stops << name } } }
402
+
403
+ # Replaced where Puma installs its own handlers, which is before it
404
+ # binds the port, so no signal reaches Puma's. setup_signals is private
405
+ # but has kept its name since Puma 2; without it, the handlers are
406
+ # replaced once Puma has booted.
407
+ replaced = launcher.respond_to?(:setup_signals, true)
408
+ if replaced
409
+ launcher.singleton_class.prepend(Module.new do
410
+ define_method(:setup_signals) do
411
+ super()
412
+ trap_stops.call
413
+ end
414
+ private :setup_signals
415
+ end)
416
+ end
417
+
418
+ events = launcher.events
419
+ # Puma 7 renamed on_booted; the old name still works there, with a warning.
420
+ events.public_send(events.respond_to?(:after_booted) ? :after_booted : :on_booted) do
421
+ trap_stops.call unless replaced
422
+ booted << true
423
+ end
424
+
425
+ Thread.new do
426
+ stops.pop
427
+ # Puma can stop only a server it has started: the port is bound before
428
+ # the server exists, and a stop asked for in between was dropped.
429
+ booted.pop
430
+ launcher.stop
431
+ sleep STOP_DEADLINE
432
+ $stderr.puts "[rails-ai-context] Server did not stop within #{STOP_DEADLINE}s of the signal; exiting."
433
+ exit!(1)
434
+ end
435
+ end
436
+
437
+ # What a non-loopback bind does depends on the SDK. One with DNS rebinding
438
+ # protection answers only a loopback Host header, so a client that
439
+ # addresses the machine by IP or name is refused with 403 "Invalid Host
440
+ # header" - while one that sends `Host: localhost` itself gets every tool.
441
+ # The old warning said the bind served the network, which was true of
442
+ # neither kind of client. An SDK without the check does serve everyone.
443
+ def bind_warning(bind)
444
+ prefix = "[rails-ai-context] WARNING: MCP HTTP transport binding to #{bind} opens its port to the network, with no authentication."
445
+ unless host_checked?
446
+ return "#{prefix} Every tool answers whoever reaches it. Use 127.0.0.1 (default) unless you have external auth in place."
447
+ end
448
+
449
+ "#{prefix} The MCP SDK answers only requests whose Host header is 127.0.0.1, ::1 or localhost, so a client that " \
450
+ "addresses this machine by IP or name gets 403 \"Invalid Host header\", while one that sends Host: localhost gets " \
451
+ "every tool. Bind it so for a container whose port is published to the host, where the client still connects to " \
452
+ "localhost; otherwise keep 127.0.0.1 (default)."
453
+ end
454
+
455
+ def host_checked?
456
+ MCP::Server::Transports::StreamableHTTPTransport.instance_method(:initialize).parameters.any? { |_, name| name == :allowed_hosts }
457
+ end
458
+
459
+ # Closes every session's stream so a connected client sees the server go,
460
+ # and stops the file watcher, rather than leaving both to process exit.
461
+ def stop_http(transport)
462
+ @live_reload&.stop
463
+ transport&.close
464
+ rescue StandardError => e
465
+ RailsAiContext.debug_fail(e, nil, label: "stop_http")
241
466
  end
242
467
 
243
468
  def default_rack_handler
@@ -256,14 +481,14 @@ module RailsAiContext
256
481
  # pidfile this server would then write over). Both options are load-bearing:
257
482
  # refusing the file still leaves WEB_CONCURRENCY able to start a cluster on
258
483
  # its own, and pinning the worker count still lets the file's pidfile and
259
- # preload_app! through.
484
+ # preload_app! through. The third bounds a stop; see SHUTDOWN_TIMEOUT.
260
485
  def rack_handler_options(handler, config)
261
486
  options = { Host: config.http_bind, Port: config.http_port }
262
487
  return options unless puma_handler?(handler)
263
488
 
264
489
  $stderr.puts "[rails-ai-context] Puma pinned to single mode - MCP sessions are per-process, " \
265
490
  "so config/puma.rb and WEB_CONCURRENCY are not read."
266
- options.merge(workers: 0, config_files: [ "-" ])
491
+ options.merge(workers: 0, config_files: [ "-" ], force_shutdown_after: SHUTDOWN_TIMEOUT)
267
492
  end
268
493
 
269
494
  # The handler is whatever Rackup picked, and only Puma understands these
@@ -277,28 +502,36 @@ module RailsAiContext
277
502
  # :auto - try to load `listen`, print a tip to stderr if missing
278
503
  # true - try to load `listen`, raise if missing
279
504
  # false - skip entirely
505
+ #
506
+ # Either way each tool call checks the app's files itself
507
+ # (BaseTool.refresh_if_files_changed!), so answers follow edits made
508
+ # before the call; the watch adds telling clients that files changed.
280
509
  def maybe_start_live_reload(mcp_server)
281
510
  mode = RailsAiContext.configuration.live_reload
282
-
511
+ CodeReloader.track_loaded_code!
512
+ Tools::BaseTool.check_files_per_call!(app)
283
513
  return if mode == false
284
514
 
285
515
  begin
286
516
  live_reload = LiveReload.new(app, mcp_server)
287
- live_reload.start
288
- @live_reload = live_reload
517
+ @live_reload = live_reload if live_reload.start
289
518
  rescue LoadError
290
- if mode == true
291
- raise LoadError, "Live reload requires the `listen` gem. Add to your Gemfile: gem 'listen', group: :development"
292
- end
293
-
294
- # :auto mode - skip with a tip
295
- $stderr.puts "[rails-ai-context] Live reload unavailable (add `listen` gem for auto-refresh)"
519
+ # A standalone install reaches an installed listen; the app's Gemfile
520
+ # is the place only when the gem itself is in it.
521
+ remedy = InstallMode.standalone? ? "Install it: gem install listen" : "Add to your Gemfile: gem 'listen', group: :development"
522
+ raise LoadError, "Live reload requires the `listen` gem. #{remedy}" if mode == true
523
+
524
+ # :auto mode - skip with a tip. Answers still follow edits: each tool
525
+ # call checks the app's files.
526
+ $stderr.puts "[rails-ai-context] Live reload off: no `listen` gem, so clients are not told when files change. #{remedy}"
296
527
  end
297
528
  end
298
529
 
299
530
  def build_rack_app(transport)
300
531
  lambda do |env|
301
- McpEdge.rack_call(env, transport: transport) do
532
+ if McpEdge.mcp_request?(env)
533
+ McpEdge.serve(env, transport)
534
+ else
302
535
  [ 404, { "Content-Type" => "application/json" }, [ '{"error":"Not found"}' ] ]
303
536
  end
304
537
  end