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
@@ -6,10 +6,11 @@ require "json"
6
6
  module RailsAiContext
7
7
  # What the three HTTP entry points - the standalone rack app, the Rack
8
8
  # middleware and the engine controller - have in common: how a transport is
9
- # built, and what a transport failure looks like on the wire. Their genuine
10
- # differences (streaming, header handling, memoization lifetime) stay with
11
- # them.
9
+ # built, which conversation a request belongs to, and what a transport
10
+ # failure looks like on the wire. Their genuine differences (streaming,
11
+ # header handling, memoization lifetime) stay with them.
12
12
  module McpEdge
13
+ INVALID_REQUEST = -32600
13
14
  INTERNAL_ERROR = -32603
14
15
 
15
16
  class << self
@@ -17,11 +18,15 @@ module RailsAiContext
17
18
  # into a JSON-RPC reply, so each has to answer in JSON-RPC shape itself
18
19
  # or leave the client's request loop hanging on a non-JSON body.
19
20
  def internal_error_frame(error)
20
- {
21
- jsonrpc: "2.0",
22
- error: { code: INTERNAL_ERROR, message: "Internal error: #{error.message}" },
23
- id: nil
24
- }.to_json
21
+ error_frame(INTERNAL_ERROR, "Internal error: #{error.message}")
22
+ end
23
+
24
+ # JSON.generate rather than to_json: ActiveSupport replaces to_json,
25
+ # and in an app whose encoder raises (Rails 8.0 under json 3: "unknown
26
+ # keyword: quirks_mode") the frame that should name the failure failed
27
+ # with it, so the client got the web server's plain-text 500.
28
+ def error_frame(code, message)
29
+ JSON.generate({ jsonrpc: "2.0", error: { code: code, message: message }, id: nil })
25
30
  end
26
31
 
27
32
  # The Rack-shaped answer, for the two entry points that return triples.
@@ -31,35 +36,180 @@ module RailsAiContext
31
36
  [ 500, { "Content-Type" => "application/json" }, [ internal_error_frame(error) ] ]
32
37
  end
33
38
 
34
- # The whole Rack-shaped request: the path match (trailing slash
35
- # included), session scoping, dispatch and error containment. The
36
- # middleware and the standalone server used to state these ten lines
37
- # separately, and only the non-match branch differs, so the caller
38
- # supplies it as the block. The block runs outside the rescue: an
39
- # exception from the app behind the middleware is the app's to handle,
40
- # not an MCP error frame.
41
- def rack_call(env, transport:)
39
+ # Whether a request is for the MCP endpoint, trailing slash included.
40
+ def mcp_request?(env)
42
41
  path = RailsAiContext.configuration.http_path
43
- return yield unless env["PATH_INFO"] == path || env["PATH_INFO"] == "#{path}/"
44
-
45
- begin
46
- request = Rack::Request.new(env)
47
- Tools::BaseTool.with_session_for(env) do
48
- transport.handle_request(request)
49
- end
50
- rescue => e
51
- internal_error_response(e)
52
- end
42
+ env["PATH_INFO"] == path || env["PATH_INFO"] == "#{path}/"
43
+ end
44
+
45
+ # One MCP request, Rack-shaped: session scoping, dispatch and error
46
+ # containment. Only a transport failure becomes an MCP error frame; the
47
+ # middleware calls the app behind it outside this, so the app's own
48
+ # exceptions stay the app's to handle.
49
+ def serve(env, transport)
50
+ request = Rack::Request.new(env)
51
+ session = Tools::BaseTool.session_from(env)
52
+ status, headers, body = Tools::BaseTool.with_session(session) { transport.handle_request(request) }
53
+ [ status, headers, session_scoped(body, session) ]
54
+ rescue => e
55
+ internal_error_response(e)
56
+ end
57
+
58
+ # The SDK answers a request inside a session with a streaming body, and
59
+ # the tool runs when the web server calls that body - after `serve` has
60
+ # returned and the scope around handle_request is gone. Every client's
61
+ # calls then landed in the one default record, so the scope travels
62
+ # with the body.
63
+ def session_scoped(body, session)
64
+ return body unless body.respond_to?(:call) && !body.respond_to?(:each)
65
+
66
+ ->(stream) { Tools::BaseTool.with_session(session) { body.call(stream) } }
67
+ end
68
+
69
+ # The middleware and the engine ride the app's own web server, which in
70
+ # a deployed environment is the one the network reaches, and every tool
71
+ # answers whoever asks. Serving there is the app's decision, so without
72
+ # it the answer is a refusal that says how to make it. Deployed is any
73
+ # environment but development and test: a staging app is reached the
74
+ # way production is.
75
+ def production_refusal
76
+ config = RailsAiContext.configuration
77
+ env = RailsAiContext.environment_name
78
+ return nil if config.allow_http_in_production || LOCAL_ENVIRONMENTS.include?(env)
79
+
80
+ log_production_refusal(env)
81
+ [ 403, { "Content-Type" => "application/json" }, [ error_frame(INVALID_REQUEST, production_refusal_message(env)) ] ]
82
+ end
83
+
84
+ def production_refusal_message(env)
85
+ "rails-ai-context does not serve MCP over HTTP in the #{env} environment, where every tool " \
86
+ "would answer whoever can reach this app. To serve it here, put the endpoint behind your app's " \
87
+ "authentication and set config.allow_http_in_production = true."
88
+ end
89
+
90
+ # The engine's transport, one per process, so every session it opens is
91
+ # found again on the next request, whatever the app reloads in between.
92
+ def engine_transport
93
+ ENGINE_TRANSPORT_LOCK.synchronize { @engine_transport ||= build_transport(answered_whole: true) }
53
94
  end
54
95
 
55
- # Memoization is the caller's: the middleware holds one per instance
56
- # and the controller one per class. The standalone server builds its
57
- # own transport because start_http also needs the underlying server
58
- # for the banner and live reload.
59
- def build_transport(app = nil)
96
+ # Drops the engine's transport and closes it, which ends its sessions
97
+ # and stops its reaper; the next request builds a fresh one.
98
+ def reset_engine_transport!
99
+ transport = ENGINE_TRANSPORT_LOCK.synchronize { @engine_transport.tap { @engine_transport = nil } }
100
+ transport.close if transport.respond_to?(:close)
101
+ end
102
+
103
+ # Memoization is the caller's: the middleware holds one per instance,
104
+ # and the engine one per process (engine_transport). The standalone
105
+ # server builds its own transport because start_http also needs the
106
+ # underlying server for the banner and live reload.
107
+ #
108
+ # The middleware and the engine never start live reload; their tool
109
+ # calls check the app's files themselves, as every server's do.
110
+ def build_transport(app = nil, answered_whole: false)
60
111
  app ||= Rails.application
61
- MCP::Server::Transports::StreamableHTTPTransport.new(Server.new(app, transport: :http).build)
112
+ options = host_check_options
113
+ options = options.merge(answered_whole_options) if answered_whole
114
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(Server.new(app, transport: :http).build, **options)
115
+ transport.extend(AnsweredWhole) if answered_whole
116
+ CodeReloader.track_loaded_code!
117
+ Tools::BaseTool.check_files_per_call!(app)
118
+ transport
119
+ end
120
+
121
+ # The SDK answers only the Host 127.0.0.1, ::1 or localhost, and its
122
+ # allowed_hosts takes exact names only: none of config.hosts' domain,
123
+ # pattern and address forms. These transports ride the app's own server,
124
+ # so its config.hosts decides instead (host_refusal), and the SDK's
125
+ # check is turned off where the SDK lets it be.
126
+ def host_check_options
127
+ return {} unless defined?(ActionDispatch::HostAuthorization)
128
+
129
+ transport_option?(:dns_rebinding_protection) ? { dns_rebinding_protection: false } : {}
130
+ end
131
+
132
+ # The engine answers each request whole (McpController), so it cannot
133
+ # keep open the stream `subscriptions/listen` delivers notifications
134
+ # on: the answer would be the acknowledgement alone, and the client,
135
+ # told it was subscribed, would hear nothing more. Told not to serve
136
+ # it, the SDK answers the method as one it does not have; an SDK older
137
+ # than the method has nothing to turn off.
138
+ def answered_whole_options
139
+ transport_option?(:serve_subscriptions_listen) ? { serve_subscriptions_listen: false } : {}
140
+ end
141
+
142
+ # The Host and Origin checks the SDK made, made by the app's rules: the
143
+ # Host as Rails' HostAuthorization judges it against config.hosts, its
144
+ # exclusions included, so the endpoint answers every name the app does
145
+ # (`myapp.localhost` in development, the app's own domain in
146
+ # production) and refuses the rest, a rebinding attacker's domain
147
+ # among them. A browser's request from another origin is refused, as
148
+ # the SDK refuses it.
149
+ def host_refusal(env, app = Rails.application)
150
+ message = if !host_allowed?(env, app) then "Forbidden: Invalid Host header"
151
+ elsif env["HTTP_ORIGIN"] && !same_origin?(env["HTTP_ORIGIN"], env["HTTP_HOST"]) then "Forbidden: Invalid Origin header"
152
+ end
153
+ message && [ 403, { "Content-Type" => "application/json" }, [ error_frame(INVALID_REQUEST, message) ] ]
154
+ end
155
+
156
+ private
157
+
158
+ def host_allowed?(env, app)
159
+ config = app.config
160
+ exclude = config.respond_to?(:host_authorization) && config.host_authorization.is_a?(Hash) ? config.host_authorization[:exclude] : nil
161
+ judge = ActionDispatch::HostAuthorization.new(->(_env) { :allowed }, config.hosts, exclude: exclude, response_app: ->(_env) { :blocked })
162
+ judge.call(env.dup) == :allowed
163
+ rescue StandardError
164
+ # Rails' own rules unreadable: the SDK's, a loopback name only.
165
+ LOOPBACK_HOSTS.include?(env["HTTP_HOST"].to_s.downcase.sub(/:\d+\z/, "").delete_prefix("[").delete_suffix("]"))
166
+ end
167
+
168
+ # The SDK's test: the Origin's host and port are the request's own, a
169
+ # scheme's default port dropped from both.
170
+ def same_origin?(origin, host)
171
+ return false if host.nil?
172
+
173
+ origin = origin.downcase
174
+ default_port = origin.start_with?("https://") ? ":443" : ":80"
175
+ origin.sub(%r{\Ahttps?://}, "").delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
176
+ end
177
+
178
+ # Once per process: a client that keeps retrying would otherwise fill
179
+ # the production log with the same line.
180
+ def log_production_refusal(env)
181
+ return if @production_refusal_logged
182
+
183
+ @production_refusal_logged = true
184
+ RailsAiContext.log_warn "[rails-ai-context] Refused an MCP request. #{production_refusal_message(env)}"
185
+ end
186
+
187
+ # Whether the installed SDK's transport takes this keyword: the gemspec
188
+ # allows SDKs from before it took several of them.
189
+ def transport_option?(name)
190
+ MCP::Server::Transports::StreamableHTTPTransport.instance_method(:initialize).parameters.any? { |_, param| param == name }
62
191
  end
63
192
  end
193
+
194
+ # The engine's transport, which answers each request whole: what a tool
195
+ # sends during a call reaches the client with the answer. A request a
196
+ # tool makes of the client mid-call (sampling, elicitation, roots, ping)
197
+ # would get there only once nothing waited for its reply, so the tool
198
+ # would wait out the SDK's timeout - 600 seconds on mcp 1.7, without end
199
+ # on 0.13 - holding a server thread, and in development every code reload
200
+ # and every request after an edit behind it. It fails at once instead,
201
+ # saying why.
202
+ module AnsweredWhole
203
+ def send_request(method, *_params, **_routing)
204
+ raise "#{method} needs the client to answer during the call, and the mounted engine answers each request " \
205
+ "whole: nothing it sends reaches the client before the call is over. Serve a tool that asks the " \
206
+ "client over stdio or the standalone HTTP server (rails-ai-context serve --transport http)."
207
+ end
208
+ end
209
+
210
+ # The environments the endpoint answers in without allow_http_in_production.
211
+ LOCAL_ENVIRONMENTS = %w[development test].freeze
212
+ LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
213
+ ENGINE_TRANSPORT_LOCK = Mutex.new
64
214
  end
65
215
  end
@@ -14,8 +14,12 @@ module RailsAiContext
14
14
  @mutex = Mutex.new
15
15
  end
16
16
 
17
+ # The transport is built on the first MCP request, not the first request
18
+ # of any kind: building it builds the whole MCP server.
17
19
  def call(env)
18
- McpEdge.rack_call(env, transport: transport) { @app.call(env) }
20
+ return @app.call(env) unless McpEdge.mcp_request?(env)
21
+
22
+ McpEdge.production_refusal || McpEdge.host_refusal(env) || McpEdge.serve(env, transport)
19
23
  end
20
24
 
21
25
  private
@@ -1,14 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- # Resolves pending-migration status the same way Rails itself does for the
5
- # "Migrations are pending" dev error page (ActiveRecord::Migration.check_pending!
6
- # walks pool.migration_context.open.pending_migrations). MigrationContext moved
7
- # from the connection object to the connection pool between Rails 7.0 and 7.1,
8
- # and ActiveRecord::Migrator.new's signature changed to require a schema_migration
9
- # + internal_metadata pair it can no longer build on its own - so a single
10
- # hardcoded construction path breaks on part of the range this gem supports.
11
- # The respond_to? cascade below picks whichever construction the loaded
4
+ # Resolves pending-migration status the way Rails' own
5
+ # MigrationContext#needs_migration? reads it, and without writing: the
6
+ # migration files Rails loads, minus the versions schema_migrations holds
7
+ # (none when the table is not there). MigrationContext#open, which the
8
+ # "Migrations are pending" page walks, builds a Migrator, and building one
9
+ # creates schema_migrations and ar_internal_metadata in a database that
10
+ # lacks them.
11
+ #
12
+ # The MigrationContext itself moved from the connection object to the
13
+ # connection pool between Rails 7.0 and 7.1, and from 7.1 on it takes a
14
+ # schema_migration + internal_metadata pair - so a single hardcoded
15
+ # construction path breaks on part of the range this gem supports. The
16
+ # respond_to? cascade below picks whichever construction the loaded
12
17
  # ActiveRecord version actually exposes instead of guessing from a version
13
18
  # number.
14
19
  module MigrationStatus
@@ -19,23 +24,76 @@ module RailsAiContext
19
24
  def self.pending(migrate_dir)
20
25
  dirs = Array(migrate_dir).select { |dir| Dir.exist?(dir) }
21
26
  return nil if dirs.empty? || !defined?(ActiveRecord::Base)
27
+ # A database that is not there yet, said without creating its file.
28
+ return nil if DatabaseFile.missing(ActiveRecord::Base.connection_db_config)
22
29
 
23
- context = migration_context(dirs)
24
- begin
25
- context.open.pending_migrations.map { |m| { version: m.version.to_s, name: m.name } }
26
- rescue ActiveRecord::MigrationError
27
- # A future or duplicate version stops Rails loading the directory; the applied set still answers.
28
- PendingMigrations.for(migrate_dir: dirs, applied: context.get_all_versions)
29
- end
30
+ pending_in(migration_context(dirs), dirs)
30
31
  rescue => e
31
32
  RailsAiContext.debug_fail(e, nil, label: "MigrationStatus.pending")
32
33
  end
33
34
 
34
- # @return [ActiveRecord::MigrationContext]
35
- def self.migration_context(migrate_dir)
36
- pool = ActiveRecord::Base.connection_pool
35
+ # One database's pending migrations, as `db:migrate:status:<name>` reads
36
+ # them: from that database's own schema_migrations, through a pool of its
37
+ # own that is removed after, so the app's connections are left as they
38
+ # are. The primary is asked through the app's own pool. The connection is
39
+ # made first, so a database that does not exist, or a server that does
40
+ # not answer, says so rather than reading as nothing pending; a SQLite
41
+ # file that is not there is said to be missing without connecting, which
42
+ # would create it.
43
+ #
44
+ # @param db_config [ActiveRecord::DatabaseConfigurations::DatabaseConfig]
45
+ # @return [Hash] { pending: [{ version:, name: }, ...] }, or { error: exception }
46
+ def self.of_database(db_config, migrate_dir, primary: false)
47
+ missing = DatabaseFile.missing(db_config)
48
+ return { error: missing } if missing
49
+
50
+ dirs = Array(migrate_dir).select { |dir| Dir.exist?(dir) }
51
+ with_pool(db_config, primary: primary) do |pool|
52
+ pool.with_connection(&:verify!)
53
+ { pending: dirs.empty? ? [] : pending_in(migration_context(dirs, pool), dirs) }
54
+ end
55
+ rescue StandardError => e
56
+ { error: e }
57
+ end
58
+
59
+ def self.pending_in(context, dirs)
60
+ applied = context.get_all_versions
61
+ context.migrations.reject { |m| applied.include?(m.version) }.map { |m| { version: m.version.to_s, name: m.name } }
62
+ rescue ActiveRecord::MigrationError
63
+ # A version Rails will not load (one past tomorrow, a name it cannot
64
+ # read) stops it reading the directory; the applied set still answers.
65
+ PendingMigrations.for(migrate_dir: dirs, applied: context.get_all_versions)
66
+ end
67
+
68
+ # What a pool established for another database belongs to: Rails names a
69
+ # pool by its owner, and an owner that is no primary class leaves the
70
+ # app's own pools alone (ActiveRecord::PendingMigrationConnection, which
71
+ # Rails 7.0 lacks and 7.1 shapes differently).
72
+ class TemporaryOwner
73
+ def self.primary_class?
74
+ false
75
+ end
37
76
 
38
- # Rails 7.1+: MigrationContext takes a schema_migration + internal_metadata
77
+ def self.current_preventing_writes
78
+ false
79
+ end
80
+ end
81
+
82
+ def self.with_pool(db_config, primary:)
83
+ return yield(ActiveRecord::Base.connection_pool) if primary
84
+
85
+ handler = ActiveRecord::Base.connection_handler
86
+ begin
87
+ yield handler.establish_connection(db_config, owner_name: TemporaryOwner)
88
+ ensure
89
+ handler.remove_connection_pool(TemporaryOwner.name)
90
+ end
91
+ end
92
+ private_class_method :with_pool
93
+
94
+ # @return [ActiveRecord::MigrationContext]
95
+ def self.migration_context(migrate_dir, pool = ActiveRecord::Base.connection_pool)
96
+ # Rails 7.2+: MigrationContext takes a schema_migration + internal_metadata
39
97
  # pair (per-connection bookkeeping objects) that only the pool knows how
40
98
  # to build - construct explicitly with OUR migrate_dir rather than
41
99
  # calling pool.migration_context directly, since that resolves its own
@@ -46,12 +104,17 @@ module RailsAiContext
46
104
  return ActiveRecord::MigrationContext.new(migrate_dir, pool.schema_migration, pool.internal_metadata)
47
105
  end
48
106
 
49
- # Rails 7.0: MigrationContext.new(migrations_paths, schema_migration =
50
- # SchemaMigration) - construct with OUR migrate_dir for the same reason
51
- # as above. The connection's own migration_context resolves the app's
52
- # configured migrations_paths and ignores the directory being asked
53
- # about, silently reporting zero pending migrations.
54
- ActiveRecord::MigrationContext.new(migrate_dir)
107
+ # Rails 7.1 keeps the pair on the connection, and Rails 7.0 the
108
+ # schema_migration alone - construct with OUR migrate_dir for the same
109
+ # reason as above. The connection's own migration_context resolves the
110
+ # app's configured migrations_paths and ignores the directory being
111
+ # asked about, silently reporting zero pending migrations.
112
+ connection = pool.connection
113
+ if connection.respond_to?(:internal_metadata)
114
+ return ActiveRecord::MigrationContext.new(migrate_dir, connection.schema_migration, connection.internal_metadata)
115
+ end
116
+
117
+ ActiveRecord::MigrationContext.new(migrate_dir, connection.schema_migration)
55
118
  end
56
119
  end
57
120
  end
@@ -37,14 +37,19 @@ module RailsAiContext
37
37
  # Bundler re-execs exactly here: `require "bundler/setup"` runs
38
38
  # inside this block, and auto_switch re-execs when the lockfile
39
39
  # names a different Bundler than the one running.
40
- saved_stdout.close_on_exec = false
41
- ENV[STDOUT_FD_ENV] = saved_stdout.fileno.to_s
40
+ if Gem.win_platform?
41
+ arm_exec(saved_stdout)
42
+ else
43
+ saved_stdout.close_on_exec = false
44
+ ENV[STDOUT_FD_ENV] = saved_stdout.fileno.to_s
45
+ end
42
46
  end
43
47
  STDOUT.reopen($stderr) if saved_stdout
44
48
  $stdout = $stderr
45
49
  yield(saved_stdout || original)
46
50
  ensure
47
51
  if saved_stdout
52
+ @exec_stdout = nil
48
53
  STDOUT.reopen(saved_stdout)
49
54
  # Closed for real, and the pointer goes with it: the descriptor
50
55
  # exists to survive one exec, and leaving it open with close-on-exec
@@ -56,6 +61,47 @@ module RailsAiContext
56
61
  $stdout = original
57
62
  end
58
63
 
64
+ # Windows has no exec: Ruby starts the new image as a child that takes
65
+ # the standard handles as they stand and no other descriptor, so a
66
+ # descriptor named in the environment means nothing there. An exec
67
+ # inside the quarantine puts fd 1 back on the real stdout first, for the
68
+ # new image to start with.
69
+ module ExecWithRealStdout
70
+ def exec(...)
71
+ OutputGuard.restore_for_exec
72
+ super(...)
73
+ end
74
+ end
75
+
76
+ # The same for a bare `exec`, which is Kernel's private instance method;
77
+ # Kernel.exec and Process.exec are public.
78
+ module PrivateExecWithRealStdout
79
+ private
80
+
81
+ def exec(...)
82
+ OutputGuard.restore_for_exec
83
+ super(...)
84
+ end
85
+ end
86
+
87
+ def self.arm_exec(saved_stdout)
88
+ @exec_stdout = saved_stdout
89
+ return if @exec_patched
90
+
91
+ Kernel.prepend(PrivateExecWithRealStdout)
92
+ Kernel.singleton_class.prepend(ExecWithRealStdout)
93
+ Process.singleton_class.prepend(ExecWithRealStdout)
94
+ @exec_patched = true
95
+ end
96
+ private_class_method :arm_exec
97
+
98
+ def self.restore_for_exec
99
+ io = @exec_stdout
100
+ STDOUT.reopen(io) if io && !io.closed?
101
+ rescue IOError, SystemCallError
102
+ nil
103
+ end
104
+
59
105
  # The descriptor an earlier image of this process saved, when there is
60
106
  # one and it is still open; a fresh dup of fd 1 otherwise.
61
107
  def self.saved_stdout_io
@@ -101,7 +101,7 @@ module RailsAiContext
101
101
  # it is missing, sensitive or a symlink out of that directory. A lockfile
102
102
  # past max_file_size still exists.
103
103
  def outside_file(dir, name)
104
- found = SafePath.locate(name, under: dir)
104
+ found = SafePath.locate(name, under: dir, links: false)
105
105
  found.realpath if found.ok? || found.refusal == :too_large
106
106
  end
107
107
 
@@ -39,24 +39,20 @@ module RailsAiContext
39
39
  # unshifts wins over app/views even when it is nested inside it.
40
40
  def view_dirs(root)
41
41
  prepended, appended = declared(root)[:views].partition { |direction, _dir| direction == :prepend }
42
- spelled = ->(list) { list.map { |_direction, dir| File.join(root.to_s, dir) } }
42
+ spelled = ->(list) { list.map { |_direction, dir| File.join(root.to_s, dir) }.reject { |dir| linked_out?(dir, root) } }
43
43
  engines = RunCache.fetch([ :engine_view_dirs, root.to_s ]) do
44
44
  enclosing_engine_roots(root).map { |engine| File.join(engine, "app/views") }.select { |dir| Dir.exist?(dir) }
45
45
  end
46
46
  (spelled.(prepended) + dirs_for(root, "app/views") + spelled.(appended) + engines).uniq.freeze
47
47
  end
48
48
 
49
- # Every initializer Rails loads: railties globs config/initializers with **/*.rb, in path order.
50
- # A symlink that resolves outside the app root, or nowhere, is left out.
49
+ # Every initializer Rails loads: railties globs config/initializers with **/*.rb, in path order,
50
+ # a glob that enters no linked directory below it. A symlink out of the app (linked_out?), or
51
+ # to nothing, is left out.
51
52
  def initializer_paths(root)
52
- real_root = File.realpath(root.to_s)
53
53
  Dir.glob(File.join(root.to_s, "config", "initializers", "**", "*.rb")).sort.select do |path|
54
- SafePath.contained?(File.realpath(path), real_root)
55
- rescue SystemCallError
56
- false
54
+ File.file?(path) && !linked_out?(path, root)
57
55
  end
58
- rescue SystemCallError
59
- []
60
56
  end
61
57
 
62
58
  # The initializers an app spells for `name`, however it spells them: a
@@ -251,7 +247,7 @@ module RailsAiContext
251
247
  relative = RunCache.fetch([ :dirs_for, root_key(root), kind, extra ]) do
252
248
  resolve_dirs(root, kind).map { |dir| dir.start_with?("#{root}/") ? dir.delete_prefix("#{root}/") : dir }
253
249
  end
254
- relative.map { |dir| dir.start_with?(File::SEPARATOR) ? dir : File.join(root, dir) }.freeze
250
+ relative.map { |dir| SafePath.absolute?(dir) ? dir : File.join(root, dir) }.freeze
255
251
  end
256
252
  end
257
253
 
@@ -304,12 +300,49 @@ module RailsAiContext
304
300
  # Inside the app root, or inside the engine its test/dummy runs in.
305
301
  def project_file?(path, root)
306
302
  real = File.realpath(path)
307
- dirs = RunCache.fetch([ :project_dirs, root.to_s ]) { [ root.to_s, *enclosing_engine_roots(root) ].map { |dir| root_key(dir) } }
308
- dirs.any? { |dir| SafePath.contained?(real, dir) }
303
+ project_dirs(root).any? { |dir| SafePath.contained?(real, dir) }
304
+ rescue SystemCallError
305
+ false
306
+ end
307
+
308
+ # A path the app spells inside the project that a symlink carries out of
309
+ # it: to a place neither the app root, the engine its test/dummy runs in,
310
+ # nor a directory linked in from the app's repository holds
311
+ # (SafePath.linked_in). A pack linked into packs/ from elsewhere in the
312
+ # repository keeps its files, as Zeitwerk follows it; a link to anywhere
313
+ # outside the repository, or a file linked out of the app and of the
314
+ # linked-in directory it sits in, does not. A path spelled outside the
315
+ # project, such as a gem's, is not one, nor is one that resolves nowhere:
316
+ # nothing can be read through it.
317
+ def linked_out?(path, root)
318
+ # Resolved as given: a spelled `..` after a link climbs from where the link leads.
319
+ real = SafePath.real_file(path.to_s)
320
+ return false if project_dirs(root).any? { |dir| SafePath.contained?(real, dir) }
321
+
322
+ spelled = File.expand_path(path.to_s)
323
+ base = [ root.to_s, *enclosing_engine_roots(root) ].map { |dir| File.expand_path(dir) }
324
+ .find { |dir| spelled.start_with?(SafePath.dir_prefix(dir)) }
325
+ return false unless base
326
+
327
+ SafePath.linked_in(path, real, base, root).nil?
309
328
  rescue SystemCallError
310
329
  false
311
330
  end
312
331
 
332
+ # Whether a directory link that leads to `real` is one a walk enters: one
333
+ # inside the app's repository or the project, never one back up over
334
+ # `start`, the directory being walked, which would only walk it again.
335
+ def enter_link?(real, start, root)
336
+ return false if SafePath.contained?(start, real)
337
+
338
+ SafePath.contained?(real, SafePath.link_bound(root)) || project_dirs(root).any? { |dir| SafePath.contained?(real, dir) }
339
+ end
340
+
341
+ def project_dirs(root)
342
+ RunCache.fetch([ :project_dirs, root.to_s ]) { [ root.to_s, *enclosing_engine_roots(root) ].map { |dir| root_key(dir) } }
343
+ end
344
+ private_class_method :project_dirs
345
+
313
346
  def root_key(root)
314
347
  File.realpath(root)
315
348
  rescue SystemCallError
@@ -323,7 +356,7 @@ module RailsAiContext
323
356
 
324
357
  def resolve_dirs(root, kind)
325
358
  places(root, kind).flat_map { |_shown, path, glob| glob ? Dir.glob(path).sort : [ path ] }
326
- .uniq.select { |dir| Dir.exist?(dir) }.freeze
359
+ .uniq.select { |dir| Dir.exist?(dir) && !linked_out?(dir, root) }.freeze
327
360
  end
328
361
  private_class_method :resolve_dirs
329
362