rails-ai-context 5.32.2 → 5.33.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 (188) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1287 -0
  3. data/CONTEXT.md +26 -2
  4. data/README.md +13 -9
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +114 -84
  7. data/docs/ARCHITECTURE.md +6 -4
  8. data/docs/CLI.md +79 -6
  9. data/docs/COMPATIBILITY.md +65 -1
  10. data/docs/CONFIGURATION.md +30 -11
  11. data/docs/CUSTOM_TOOLS.md +2 -1
  12. data/docs/FAQ.md +16 -6
  13. data/docs/GUIDE.md +117 -71
  14. data/docs/INTROSPECTORS.md +8 -7
  15. data/docs/QUICKSTART.md +3 -3
  16. data/docs/RECIPES.md +1 -1
  17. data/docs/SECURITY.md +109 -25
  18. data/docs/SETUP.md +30 -6
  19. data/docs/STANDALONE.md +10 -3
  20. data/docs/TOOLS.md +145 -34
  21. data/docs/TROUBLESHOOTING.md +90 -12
  22. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  23. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  24. data/exe/rails-ai-context +733 -104
  25. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  26. data/lib/rails_ai_context/ast_cache.rb +7 -0
  27. data/lib/rails_ai_context/base_mixins.rb +8 -8
  28. data/lib/rails_ai_context/boot_manager.rb +22 -3
  29. data/lib/rails_ai_context/change_watch.rb +14 -11
  30. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  31. data/lib/rails_ai_context/cli/entry_boot.rb +53 -20
  32. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  33. data/lib/rails_ai_context/code_reloader.rb +115 -1
  34. data/lib/rails_ai_context/concern_membership.rb +3 -1
  35. data/lib/rails_ai_context/concern_paths.rb +8 -3
  36. data/lib/rails_ai_context/configuration.rb +41 -9
  37. data/lib/rails_ai_context/context_file_report.rb +19 -2
  38. data/lib/rails_ai_context/database_file.rb +26 -0
  39. data/lib/rails_ai_context/doctor.rb +1122 -184
  40. data/lib/rails_ai_context/engine.rb +11 -1
  41. data/lib/rails_ai_context/erb_source.rb +19 -0
  42. data/lib/rails_ai_context/executable.rb +21 -0
  43. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  44. data/lib/rails_ai_context/file_walk.rb +61 -10
  45. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  46. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  47. data/lib/rails_ai_context/gem_lock.rb +176 -26
  48. data/lib/rails_ai_context/git_ignore.rb +22 -0
  49. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  50. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  51. data/lib/rails_ai_context/install/program.rb +209 -35
  52. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  53. data/lib/rails_ai_context/install/surface.rb +6 -1
  54. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  55. data/lib/rails_ai_context/install/workspace.rb +166 -0
  56. data/lib/rails_ai_context/install_mode.rb +71 -13
  57. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  58. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  59. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  60. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  61. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  62. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  63. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  65. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/auth_introspector.rb +10 -3
  67. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  68. data/lib/rails_ai_context/introspectors/controller_introspector.rb +56 -8
  69. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  70. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +14 -2
  71. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  73. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  75. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  76. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  77. data/lib/rails_ai_context/introspectors/gem_introspector.rb +2 -1
  78. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  80. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +2 -2
  82. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  83. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +46 -1
  84. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  85. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +3 -1
  86. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +14 -3
  87. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  88. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  89. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  90. data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -2
  91. data/lib/rails_ai_context/introspectors/model_introspector.rb +91 -5
  92. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  93. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  94. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  95. data/lib/rails_ai_context/introspectors/schema_introspector.rb +57 -4
  96. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  97. data/lib/rails_ai_context/introspectors/source_scan.rb +13 -9
  98. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  99. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +130 -11
  100. data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -3
  101. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  102. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  103. data/lib/rails_ai_context/live_reload.rb +20 -20
  104. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  105. data/lib/rails_ai_context/mcp_edge.rb +185 -32
  106. data/lib/rails_ai_context/middleware.rb +5 -1
  107. data/lib/rails_ai_context/migration_status.rb +88 -25
  108. data/lib/rails_ai_context/output_guard.rb +48 -2
  109. data/lib/rails_ai_context/package_json.rb +1 -1
  110. data/lib/rails_ai_context/path_resolver.rb +46 -13
  111. data/lib/rails_ai_context/payload.rb +55 -7
  112. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  113. data/lib/rails_ai_context/portable_path.rb +3 -2
  114. data/lib/rails_ai_context/presets.rb +15 -11
  115. data/lib/rails_ai_context/redaction.rb +254 -51
  116. data/lib/rails_ai_context/resources.rb +55 -20
  117. data/lib/rails_ai_context/route_coverage.rb +8 -0
  118. data/lib/rails_ai_context/safe_file.rb +14 -1
  119. data/lib/rails_ai_context/safe_path.rb +101 -4
  120. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  121. data/lib/rails_ai_context/schema_version.rb +9 -0
  122. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +86 -32
  123. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  124. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  125. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  126. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  127. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +17 -15
  128. data/lib/rails_ai_context/serializers/guide_examples.rb +141 -0
  129. data/lib/rails_ai_context/serializers/markdown_serializer.rb +142 -29
  130. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +36 -14
  131. data/lib/rails_ai_context/serializers/section_facts.rb +35 -1
  132. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  133. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  134. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  135. data/lib/rails_ai_context/server.rb +304 -71
  136. data/lib/rails_ai_context/tasks/rails_ai_context.rake +112 -43
  137. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  138. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  139. data/lib/rails_ai_context/tools/base_tool.rb +352 -41
  140. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  141. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  142. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  143. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  144. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  145. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  146. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  147. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  148. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  149. data/lib/rails_ai_context/tools/get_controllers.rb +157 -40
  150. data/lib/rails_ai_context/tools/get_conventions.rb +26 -4
  151. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  152. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  153. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  154. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  155. data/lib/rails_ai_context/tools/get_helper_methods.rb +9 -5
  156. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  157. data/lib/rails_ai_context/tools/get_job_pattern.rb +2 -1
  158. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  159. data/lib/rails_ai_context/tools/get_model_details.rb +16 -6
  160. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  161. data/lib/rails_ai_context/tools/get_routes.rb +19 -6
  162. data/lib/rails_ai_context/tools/get_schema.rb +51 -18
  163. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  164. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  165. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  166. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  167. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  168. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  169. data/lib/rails_ai_context/tools/onboard.rb +46 -6
  170. data/lib/rails_ai_context/tools/performance_check.rb +4 -2
  171. data/lib/rails_ai_context/tools/query.rb +914 -86
  172. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  173. data/lib/rails_ai_context/tools/review_changes.rb +204 -43
  174. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  175. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  176. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  177. data/lib/rails_ai_context/tools/search_docs.rb +3 -3
  178. data/lib/rails_ai_context/tools/security_scan.rb +141 -31
  179. data/lib/rails_ai_context/tools/session_context.rb +38 -4
  180. data/lib/rails_ai_context/tools/validate.rb +259 -40
  181. data/lib/rails_ai_context/tools/validate_semantics.rb +129 -55
  182. data/lib/rails_ai_context/version.rb +1 -1
  183. data/lib/rails_ai_context/vfs.rb +67 -40
  184. data/lib/rails_ai_context/view_file.rb +41 -3
  185. data/lib/rails_ai_context/watcher.rb +51 -13
  186. data/lib/rails_ai_context.rb +21 -0
  187. data/server.json +1 -1
  188. metadata +13 -1
data/CONTEXT.md CHANGED
@@ -30,16 +30,26 @@ Two senses, one per module, and neither is bare "path" in a name.
30
30
 
31
31
  ## Safe path
32
32
 
33
- A caller-supplied path resolved once, through `SafePath`, before anything reads it. Its refusal order is the contract, not an implementation detail: traversal, sensitive name, realpath, containment, sensitive realpath, file, size. A caller that reorders those checks, or repeats one of them itself, gets a different answer on a symlink or a dotfile than every other tool does, which is the divergence the module exists to end. `BaseTool.safe_glob` is the globbed-path form: realpath, containment and the sensitive-realpath check on each path a pattern yields, with the size cap left to the read that follows.
33
+ A caller-supplied path resolved once, through `SafePath`, before anything reads it. Its refusal order is the contract, not an implementation detail: traversal, sensitive name, realpath, containment (the directory asked about, or a **linked-in** directory on the way to it), sensitive realpath, file, size. A caller that reorders those checks, or repeats one of them itself, gets a different answer on a symlink or a dotfile than every other tool does, which is the divergence the module exists to end. `BaseTool.safe_glob` is the globbed-path form: realpath, containment and the sensitive-realpath check on each path a pattern yields, with the size cap left to the read that follows.
34
34
 
35
35
  Not the same as a path that merely looks harmless, and not a caller's own containment check - "the tool guards this parameter" means it hands the parameter to `SafePath` and renders whatever refusal comes back.
36
36
 
37
37
  ## Carried path
38
38
 
39
- A path the payload already holds because the gem's own walk found it - a controller's or a model's `file:`. A controller's is re-read with `SafeFile.read(File.join(root, relative))`, which checks that it is a regular file under the size cap and nothing else; a model's goes through `PortablePath.resolve` first, since it can be a `gem:` path. The reason is the walk: `SourceScan` deliberately keeps the spelling the app uses when the realpath falls outside the root, so a pack or an in-repo engine symlinked out of the root is spelled inside it, and `SafePath`'s realpath containment would refuse the very file the payload just named - the source comes back nil and a section silently empties.
39
+ A path the payload already holds because the gem's own walk found it - a controller's or a model's `file:`. A controller's is re-read with `SafeFile.read(File.join(root, relative))`, which checks that it is a regular file under the size cap and nothing else; a model's goes through `PortablePath.resolve` first, since it can be a `gem:` path. The reason is the walk: it already decided the file is the app's (see **Linked out**), keeping the spelling the app uses when the realpath falls outside the root, and a model's file can be a gem's, which `SafePath` refuses as outside the root - the source would come back nil and a section silently empty.
40
40
 
41
41
  Not an exception to the **safe path** rule, the other side of it: a caller-supplied path is untrusted and goes through `SafePath`; a carried path was produced by this gem and only needs the cap.
42
42
 
43
+ ## Linked out
44
+
45
+ A file the app spells inside its root, or inside the engine its test/dummy runs in, that a symlink takes somewhere neither of those holds nor a **linked-in** directory does: the file is a link, or sits under one. `PathResolver.linked_out?` answers it. A walk that reads what it finds drops such a file before listing it (`FileWalk`, `SourceScan`, `ViewFile.glob` and the introspectors' own globs), and so does a read of the file Ruby says a loaded class came from, so a **carried path** is never one. A link that resolves nowhere is not linked out, since nothing can be read through it, and a walk drops it as no file.
46
+
47
+ Not the **safe path** check, which refuses a path a caller supplied: this one decides which of the files the gem found itself are the app's. Booted, a class Rails loaded through such a link is still described by reflection; only its file goes unread.
48
+
49
+ ## Linked-in directory
50
+
51
+ A directory link inside the app that leads somewhere in the git work tree holding the app (`SafePath.link_bound`): a monorepo package linked into `packs/` or `app/javascript/controllers`. Its files are the app's, named the way the app spells them (`packs/billing/app/models/invoice.rb`), because Zeitwerk, the view resolver and the bundlers follow it; a file under it that links further out, past the app and the directory, is **linked out**. Outside any repository the bound is the app root, so nothing outside it is linked in. `SafePath.linked_in` finds the directory on a path's spelling, and `PathResolver.enter_link?` decides whether a walk enters one: never one back up over the directory being walked. The walks that stand for Rails' own globs - initializers, locales, migrations, seeds, rake tasks, test files - enter no linked directory, as those globs do not.
52
+
43
53
  ## Source scan
44
54
 
45
55
  The one walk over a kind of app source, across every directory `PathResolver` resolves for it: conventional layout, packs, in-repo engines. `SourceScan.paths` stats only, `each` reads the source on top of it, and `classes` names each file by its declared constant. An introspector that globs `app/<kind>` itself is the mistake this entry exists to name: it misses every pack and engine, and it names files by their path rather than by what they declare.
@@ -154,3 +164,17 @@ Two senses on different axes. Qualify it; the bare word does not say which.
154
164
  **Standalone install** - how the gem got installed: `gem install rails-ai-context`, driven through its own binary, never in the host app's Gemfile, so the `rails ai:*` rake tasks do not exist. `InstallMode.standalone?` is the decider, and the `standalone:` flag on `McpConfigGenerator` means this - it picks whether the written MCP command is the bare binary or a bundled invocation. `docs/STANDALONE.md` documents this sense only.
155
165
 
156
166
  **Standalone server** - how MCP is served: the entry point that is its own process, `rails ai:serve_http` starting `Server` with `transport: :http`, serving MCP and nothing else. The middleware and the engine controller answer the same requests from inside the app's web server; the standalone server does not need one running. It says nothing about the install - an in-Gemfile app starts it from a rake task. ADR-0003 and `McpEdge` use this sense.
167
+
168
+ ## App root
169
+
170
+ The directory a command reads as the app, decided once by `CLI::AppRoot` before anything reads the working directory, and then made the process's working directory. `--app-path` names it outright and is never walked. Otherwise it is the nearest directory at or above the working directory that passes the strict test, as `bin/rails` and Bundler find theirs: `config/application.rb`, `config/environment.rb`, or a `bin/rails` that boots an app or an engine. A working directory that passes only the loose test (source alone, any `app/**/*.rb`: a packwerk pack, an engine kept as code) is the root itself when no app holds it. The walk up never stops at a pack, `$HOME`, `node_modules`, `vendor/bundle` or a gem install.
171
+
172
+ Not the same as `Rails.root`, which only a booted app has, and not the **workspace** below, which is no app at all.
173
+
174
+ ## Workspace
175
+
176
+ A folder an editor or agent is opened at that is no app and has no app above it, but holds apps one or two levels down (`config/application.rb` only, so an engine or a pack is not one). `init` sets one up as a whole: the AI tools are chosen once, each app gets its own `.rails-ai-context.yml` and context files, since its server reads them from its own root, and the workspace's MCP configs get one server per app, since a client reads them from the folder it opened and from nowhere below. Every other command needs a single app, so it uses the only one or refuses with one `--app-path` command per app.
177
+
178
+ **Server name** - the key an entry has in a config. An app's own config calls its server `rails-ai-context`; a workspace's calls each one `rails-ai-context-<app folder>` (`Install::Workspace.server_names`), with the whole path where two apps share a folder name, and a hash where the name would be too long for the tools. `McpConfigGenerator::OWN_SERVER_NAME` matches every form, and `own_entry?` claims the ones that run the gem's server, so removal never leaves one behind and never takes an entry somebody else named that way. Not the **announced name**, which the running server gives the tool (`config.server_name`, or, in place of its default, `RAILS_AI_CONTEXT_SERVER_NAME` for one process): a workspace server announces its app first, because VS Code names tools after that and keeps 13 characters of it.
179
+
180
+ **Workspace entry** - the server entry itself: `--app-path` relative to the workspace, because the file may be committed; spelled from the tool's own name for its config's folder (`${workspaceFolder}`) where it has one, bare where the tool starts servers in the folder it was launched in. For an in-Gemfile app it adds `BUNDLE_GEMFILE` naming the Gemfile the app's bundle reads (its own, else the shared one its `config/boot.rb` names, else the one `bundle exec` run inside the app would find), because `bundle exec` started in the workspace would never find it. ADR-0005 records the per-tool facts this rests on.
data/README.md CHANGED
@@ -30,7 +30,7 @@
30
30
  **rails-ai-context** is a Ruby gem that turns your Rails app into the source of truth for AI coding assistants. Instead of guessing your schema, associations, routes and conventions from training data, the assistant asks your app: 45 read-only tools served over [MCP](https://modelcontextprotocol.io) or run from the CLI, plus generated context files for Claude Code, Cursor, GitHub Copilot, OpenCode and Codex CLI.
31
31
 
32
32
  > [!TIP]
33
- > Nothing to add to your Gemfile if you don't want to. `gem install rails-ai-context`, then `rails-ai-context init` inside any Rails app. It also works on an app that won't boot: pass `--no-boot` and the tools answer from the source files. The two that need a live app, `query` and `runtime_info`, say so.
33
+ > Nothing to add to your Gemfile if you don't want to. `gem install rails-ai-context`, then `rails-ai-context init` inside any Rails app, or in a folder of apps to set them all up with one MCP server each. It also works on an app that won't boot: pass `--no-boot` and the tools answer from the source files. The two that need a live app, `query` and `runtime_info`, say so.
34
34
 
35
35
  ## Why
36
36
 
@@ -67,7 +67,7 @@ You catch it, fix it, re-prompt, and something next to it breaks. The tokens are
67
67
  - **Three ways in**: MCP over stdio, MCP mounted inside your Rails app over HTTP, or plain CLI in any terminal.
68
68
  - **Generated context files** for Claude Code, Cursor, GitHub Copilot, OpenCode and Codex CLI, with the MCP config each tool auto-detects on project open.
69
69
  - **Live resources**: `rails://` and `rails-ai-context://` URIs that introspect fresh on every read.
70
- - **Anti-hallucination rules** shipped in every generated context file, on by default.
70
+ - **Anti-hallucination rules** shipped in each AI tool's main context file, in compact and full mode, on by default.
71
71
  - **Static tier**: when the app can't boot, tools answer from `config/routes.rb`, `db/schema.rb`, migrations and source files, and say so.
72
72
  - **Works with real app shapes**: packwerk packs, in-repo engines, multi-database schema dumps, Mongoid, API-only apps.
73
73
  - **Custom tools**: register your own `MCP::Tool` classes next to the built-in ones and test them with the bundled `TestHelper`.
@@ -78,6 +78,7 @@ You catch it, fix it, re-prompt, and something next to it breaks. The tokens are
78
78
 
79
79
  - Ruby 3.1 or newer
80
80
  - Rails 7.0 or newer
81
+ - Linux, macOS or Windows (see [Operating systems](docs/COMPATIBILITY.md#operating-systems))
81
82
  - Optional: `brakeman` for `security_scan`, `listen` for `watch`, `ripgrep` for faster `search_code`
82
83
 
83
84
  ### Install in the Gemfile
@@ -87,7 +88,7 @@ bundle add rails-ai-context --group development
87
88
  rails generate rails_ai_context:install
88
89
  ```
89
90
 
90
- The generator asks which AI tools you use and what to write, then creates the context files, the MCP config for each tool, `config/initializers/rails_ai_context.rb` and `.rails-ai-context.yml`. In a git repo with no pre-commit hook it also offers to install one. An empty answer to the tools question selects all five, and `--defaults` takes the default for each question. Re-running it is safe; it keeps what you have and adds what is missing. Every step is in [What the install generator does](docs/GUIDE.md#what-the-install-generator-does).
91
+ The generator asks which AI tools you use and what to write, then creates the context files, the MCP config for each tool, `config/initializers/rails_ai_context.rb` and `.rails-ai-context.yml`. In a git repo with no pre-commit hook it also offers to install one. An empty answer to the tools question selects all five, and `--defaults` takes the default for each question. Re-running it is safe; it keeps what you have and adds what is missing, and in a context file you wrote yourself, such as `CLAUDE.md` or `app/models/AGENTS.md`, it rewrites only the section between its `<!-- BEGIN/END rails-ai-context -->` markers. Every step is in [What the install generator does](docs/GUIDE.md#what-the-install-generator-does).
91
92
 
92
93
  Keeping your own `CLAUDE.md` and `AGENTS.md`? `rails generate rails_ai_context:install --mcp-only` writes the MCP config and leaves every context file alone.
93
94
 
@@ -129,7 +130,7 @@ bundle exec rails-ai-context serve # in-Gemfile
129
130
  rails-ai-context serve # standalone
130
131
  ```
131
132
 
132
- `rails ai:serve` starts the same server from rake.
133
+ On Windows each config starts it through `cmd /c`, since a gem's executables there are batch files. `rails ai:serve` starts the same server from rake.
133
134
 
134
135
  ### MCP over HTTP
135
136
 
@@ -137,13 +138,16 @@ Mount the server inside your app. It inherits your routing, auth and middleware,
137
138
 
138
139
  ```ruby
139
140
  # config/routes.rb
140
- mount RailsAiContext::Engine, at: "/mcp"
141
+ mount RailsAiContext::Engine, at: "/mcp" if defined?(RailsAiContext::Engine)
141
142
  ```
142
143
 
144
+ The `if` keeps the routes file loading wherever the gem is not: in production, where Bundler skips the `:development` group, and after the gem is removed. An unguarded mount raises `uninitialized constant RailsAiContext::Engine` there, and the app does not boot.
145
+
146
+ Outside development and test (production, staging) the endpoint refuses every request with a 403 unless you set `config.allow_http_in_production = true`, since it has no authentication of its own; see [Security](docs/SECURITY.md#mcp-http-transport).
147
+
143
148
  Point the client at `http://localhost:3000/mcp`. There is also a standalone HTTP process: `rails-ai-context serve --transport http --port 6029`.
144
149
 
145
- > [!WARNING]
146
- > Each connected client that opens the SSE channel holds one server thread for the life of the connection. Fine for development; raise Puma's thread count or use the standalone HTTP process if several clients share the app.
150
+ The mounted engine opens no server-push channel (it answers that `GET` with 405, as the MCP spec allows), so a connected client holds a server thread only while one of its requests runs.
147
151
 
148
152
  ### CLI
149
153
 
@@ -169,7 +173,7 @@ Tool names resolve loosely: `schema`, `get_schema` and `rails_get_schema` all wo
169
173
  | `rails ai:watch` | `rails-ai-context watch` | Regenerate on file change |
170
174
  | `rails 'ai:preset[NAME]'` | `rails-ai-context preset NAME` | Run a multi-tool preset (`architecture`, `debugging`, `migration`) |
171
175
 
172
- Flags shared by the app-reading commands: `--app-path PATH` to target another directory, `--environment ENV` to set `RAILS_ENV`, and `--no-boot` to skip the boot attempt and answer from source. Full list in the [CLI reference](docs/CLI.md).
176
+ The binary finds the app the way `bin/rails` does, so it runs from any subdirectory, and from a folder holding one app. Flags shared by the app-reading commands: `--app-path PATH` to target another directory, `--environment ENV` to set `RAILS_ENV`, and `--no-boot` to skip the boot attempt and answer from source. Full list in the [CLI reference](docs/CLI.md).
173
177
 
174
178
  ## Tools
175
179
 
@@ -213,7 +217,7 @@ Plus 9 static resources: `rails://schema`, `routes`, `conventions`, `gems`, `con
213
217
 
214
218
  ## Anti-hallucination rules
215
219
 
216
- Every generated context file (`CLAUDE.md`, `.cursor/rules/`, `.github/instructions/`, `AGENTS.md`) ships with six rules the assistant reads before writing code:
220
+ Each AI tool's main context file (`CLAUDE.md`, `AGENTS.md` for OpenCode and Codex, `.cursorrules` and `.cursor/rules/rails-mcp-tools.mdc` for Cursor, `.github/copilot-instructions.md`) ships with six rules the assistant reads before writing code, in compact and full mode alike. With `generate_root_files` off, `.claude/rules/rails-mcp-tools.md` and `.github/instructions/rails-mcp-tools.instructions.md` carry them instead:
217
221
 
218
222
  1. Verify before you write. Never reference a column, association, route, helper, method, class, partial or gem that a tool call in this turn did not confirm.
219
223
  2. Mark every assumption with `[ASSUMPTION]`. "I'd need to check X first" is a good answer.
data/Rakefile CHANGED
@@ -14,6 +14,7 @@ RSpec::Core::RakeTask.new(:spec)
14
14
  # bundle exec rake e2e:in_gemfile # just Path A
15
15
  # bundle exec rake e2e:standalone # just Path B
16
16
  # bundle exec rake e2e:zero_config # just Path C
17
+ # bundle exec rake e2e:workspace # just Path D: init in a folder of apps
17
18
  # bundle exec rake e2e:mcp # stdio + HTTP protocol specs
18
19
  namespace :e2e do
19
20
  RSpec::Core::RakeTask.new(:in_gemfile) do |t|
@@ -31,6 +32,11 @@ namespace :e2e do
31
32
  ENV["E2E"] = "1"
32
33
  end
33
34
 
35
+ RSpec::Core::RakeTask.new(:workspace) do |t|
36
+ t.pattern = "spec/e2e/workspace_install_spec.rb"
37
+ ENV["E2E"] = "1"
38
+ end
39
+
34
40
  RSpec::Core::RakeTask.new(:mcp) do |t|
35
41
  t.pattern = "spec/e2e/mcp_{stdio,http}_protocol_spec.rb"
36
42
  ENV["E2E"] = "1"
@@ -1,49 +1,93 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "delegate"
4
-
5
3
  module RailsAiContext
6
4
  # Rails controller for serving MCP over Streamable HTTP.
7
5
  # Alternative to the Rack middleware - integrates with Rails routing,
8
6
  # authentication, and middleware stack.
9
7
  #
10
- # Mount in routes: mount RailsAiContext::Engine, at: "/mcp"
8
+ # Mount in routes, guarded so the routes file still loads where the gem is
9
+ # not (production, with the gem in the development group, or after it is
10
+ # removed): mount RailsAiContext::Engine, at: "/mcp" if defined?(RailsAiContext::Engine)
11
+ #
12
+ # Every request is answered whole, on the thread Rails runs it on. The SDK
13
+ # answers a request inside a session with a body that writes an SSE stream;
14
+ # this controller runs that body into a buffer and sends the text once the
15
+ # call is done, as a plain string, which every middleware in the app's
16
+ # stack passes through (Rails 7's Rack::ETag 500'd on the callable itself).
17
+ #
18
+ # It used to stream through ActionController::Live, which runs the action
19
+ # on a thread of its own, and that deadlocked a development server. The
20
+ # request's thread holds a share of the code-loading interlock and waits
21
+ # for the Live thread to answer; the Live thread needs a share of its own
22
+ # to start; once another request has queued a code reload after an edit,
23
+ # no new share is granted until the reload runs, and the reload waits for
24
+ # the request's thread to let go of its share. With several clients
25
+ # calling while an agent edited, every request hung within seconds, and
26
+ # SIGTERM waited on them. Live's thread also kept the database connection
27
+ # a call used, and from Rails 8.0 that thread lives on in a pool, so each
28
+ # call after an edit waited ten seconds for the reload to give up on it.
29
+ # On the request's own thread the call needs no second share, the reload
30
+ # waits for it to finish, and Rails returns its connection when the
31
+ # request completes.
32
+ #
33
+ # The cost: what a tool sends during a call reaches the client with the
34
+ # answer, not as it is sent. A custom tool's progress or log notifications
35
+ # arrive together at the end, and a request a tool makes of the client
36
+ # mid-call (sampling, elicitation, roots) fails at once, since no answer
37
+ # could reach it before the call is over (McpEdge::AnsweredWhole). This
38
+ # gem's own tools send neither, and the engine opens no stream of its own
39
+ # (refuse_server_push).
11
40
  class McpController < ActionController::API
12
- # Live: the transport answers SSE-mode requests with a Rack 3 streaming
13
- # body (a Proc that writes to a stream). Handing that Proc to a plain
14
- # controller works only where the response body passes through untouched;
15
- # Rails 7.x's response buffer feeds it through Rack::ETag, which 500s on
16
- # the callable. Live's stream is exactly the interface the Proc expects
17
- # (write/close) and keeps every middleware away from the body on all
18
- # supported Rails versions.
19
- include ActionController::Live
20
-
21
- # The MCP SDK's SSE writer calls stream.flush after every event, but
22
- # Live's buffer writes straight through to the client and defines no
23
- # flush - the NoMethodError would 500 the request after the payload was
24
- # already delivered and tear down long-lived GET streams.
25
- class FlushableStream < SimpleDelegator
26
- def flush; end
41
+ # What the SDK's SSE writer needs of a stream - write, flush, close,
42
+ # closed? - held in memory until the call is done. A write after close
43
+ # raises IOError, as a closed socket's does, so the SDK drops a stream
44
+ # the answer has already left on.
45
+ class BufferedStream
46
+ def initialize
47
+ @chunks = []
48
+ @closed = false
49
+ end
50
+
51
+ def write(chunk)
52
+ raise IOError, "closed stream" if @closed
53
+
54
+ text = chunk.to_s
55
+ @chunks << text
56
+ text.bytesize
57
+ end
58
+
59
+ def flush
60
+ self
61
+ end
62
+
63
+ def close
64
+ @closed = true
65
+ nil
66
+ end
67
+
68
+ def closed?
69
+ @closed
70
+ end
71
+
72
+ def string
73
+ @chunks.join
74
+ end
27
75
  end
28
76
 
29
77
  def handle
78
+ if (refused = McpEdge.production_refusal || McpEdge.host_refusal(request.env))
79
+ return answer_rack(refused)
80
+ end
81
+ return refuse_server_push if request.get?
82
+
30
83
  Tools::BaseTool.with_session_for(request.env) do
31
84
  serve_transport
32
85
  end
33
86
  rescue => e
34
- # Once the response is committed the status and headers are already on
35
- # the wire, so a JSON-RPC frame written here cannot reach the client.
36
- # Worse, assigning a body swaps the stream out from under the thread
37
- # still draining the old one, turning a truncated SSE stream into a
38
- # garbled one. The streaming branch's ensure always closes the stream,
39
- # and closing commits, so every streaming failure lands here committed.
40
- # Hand those to Live, which logs them with a backtrace and tears the
41
- # connection down.
42
- raise if response.committed?
43
-
44
87
  # A transport failure must still answer in JSON-RPC shape. Without this
45
88
  # the exception escapes into a generic Rails 500 (HTML), breaking the
46
- # client's JSON-RPC loop.
89
+ # client's JSON-RPC loop. Nothing has been sent yet, whatever the body
90
+ # had written, so the frame replaces it whole.
47
91
  RailsAiContext.log_warn "[rails-ai-context] MCP request failed: #{e.class}: #{e.message}"
48
92
  self.status = 500
49
93
  response.headers["Content-Type"] = "application/json"
@@ -52,47 +96,39 @@ module RailsAiContext
52
96
 
53
97
  private
54
98
 
99
+ # The body is read before the status and headers are set, so a body
100
+ # that fails leaves nothing of the SDK's answer on the error frame.
55
101
  def serve_transport
56
- status_code, rack_headers, body = self.class.mcp_transport.handle_request(request)
102
+ status_code, rack_headers, body = McpEdge.engine_transport.handle_request(request)
103
+ text = read_body(body)
57
104
  self.status = status_code
58
105
  apply_transport_headers(rack_headers)
106
+ self.response_body = text
107
+ end
108
+
109
+ # The SDK's answer as one string: a plain body (initialize, an error, a
110
+ # notification's 202) joined, and a streaming one - the SSE stream a
111
+ # request inside a session gets - run here into a buffer. The tool runs
112
+ # inside it, on this thread, inside this request's session scope. The
113
+ # buffer is closed once the body returns, as the answer leaves with it.
114
+ def read_body(body)
59
115
  if body.respond_to?(:each)
60
- # Plain enumerable body (initialize, errors, JSON mode): join to a
61
- # string so Content-Length/ETag semantics stay conventional.
62
116
  chunks = []
63
117
  body.each { |chunk| chunks << chunk }
64
- body.close if body.respond_to?(:close)
65
- self.response_body = chunks.join
118
+ chunks.join
66
119
  elsif body.respond_to?(:call)
67
- stream = response.stream
68
- stream = FlushableStream.new(stream) unless stream.respond_to?(:flush)
120
+ stream = BufferedStream.new
69
121
  begin
70
122
  body.call(stream)
71
- # A GET opens the server-push channel: the transport registers the
72
- # stream and returns, expecting it to outlive this call. Live closes
73
- # the response when the action returns, so hold the thread until the
74
- # transport's keepalive (or the client) closes the stream. Live also
75
- # sends headers only on the first write - the transport writes
76
- # nothing until its first keepalive ping, so commit with an SSE
77
- # comment up front or clients sit waiting on headers.
78
- if request.get?
79
- begin
80
- stream.write(": connected\n\n")
81
- rescue IOError
82
- nil
83
- end
84
- wait_for_stream_close
85
- end
86
123
  ensure
87
- begin
88
- response.stream.close
89
- rescue StandardError
90
- nil
91
- end
124
+ stream.close
92
125
  end
126
+ stream.string
93
127
  else
94
- self.response_body = body
128
+ body
95
129
  end
130
+ ensure
131
+ body.close if body.respond_to?(:close)
96
132
  end
97
133
 
98
134
  # Rack 3 transports name their headers in lowercase, and the MCP SDK
@@ -111,43 +147,37 @@ module RailsAiContext
111
147
  end
112
148
  end
113
149
 
114
- def wait_for_stream_close
115
- sleep 0.5 until stream_finished?
116
- rescue IOError
117
- nil
150
+ # A Rack triple answered whole, for the refusals McpEdge builds.
151
+ def answer_rack((status, headers, body))
152
+ self.status = status
153
+ apply_transport_headers(headers)
154
+ self.response_body = body.join
118
155
  end
119
156
 
120
- # A client hangup aborts the buffer instead of closing it, so `closed?`
121
- # alone leaves this thread parked until the transport's next keepalive
122
- # write notices the hangup - up to the keepalive interval per dropped
123
- # client. Live's buffer reports the hangup through `connected?`; the plain
124
- # buffer used off the streaming path does not define it, so ask first.
125
- def stream_finished?
126
- stream = response.stream
127
- return true if stream.closed?
128
-
129
- stream.respond_to?(:connected?) && !stream.connected?
157
+ # The engine has nothing to push: live reload, the one thing that sends
158
+ # a server-initiated message, runs only in the standalone server. Held
159
+ # open, the GET channel carried keepalive pings and nothing else, cost a
160
+ # server thread per connected client, and a client that stayed connected
161
+ # kept `rails server` from stopping. A server without the channel answers
162
+ # the GET with 405, which clients read as "POST only".
163
+ def refuse_server_push
164
+ self.status = 405
165
+ response.headers["Allow"] = "POST, DELETE"
166
+ response.headers["Content-Type"] = "application/json"
167
+ self.response_body = McpEdge.error_frame(McpEdge::INVALID_REQUEST,
168
+ "Method not allowed: this endpoint opens no server-push stream. Send requests with POST.")
130
169
  end
131
170
 
132
171
  class << self
133
- # Class-level memoization - transport persists across requests.
134
- # Thread-safe: MCP::Server and transport are stateless for reads.
172
+ # The transport outlives this class, which the app's reloader replaces
173
+ # on every edit in development (McpEdge.engine_transport).
135
174
  def mcp_transport
136
- @transport_mutex.synchronize { @mcp_transport ||= McpEdge.build_transport }
175
+ McpEdge.engine_transport
137
176
  end
138
177
 
139
178
  def reset_transport!
140
- @transport_mutex.synchronize { @mcp_transport = nil }
141
- end
142
-
143
- private
144
-
145
- def inherited(subclass)
146
- super
147
- subclass.instance_variable_set(:@transport_mutex, Mutex.new)
179
+ McpEdge.reset_engine_transport!
148
180
  end
149
181
  end
150
-
151
- @transport_mutex = Mutex.new
152
182
  end
153
183
  end
data/docs/ARCHITECTURE.md CHANGED
@@ -115,7 +115,7 @@ Five modules answer questions every introspector used to answer for itself:
115
115
 
116
116
  - **PathResolver** - where a kind of app code lives: `app/<kind>`, packs, and the in-repo code roots read from the tree (a directory holding its own `app/` plus a gemspec, `plugin.rb` or `lib/**/engine.rb`), memoized per root
117
117
  - **SourceScan** - one walk over a kind of app source, across every directory `PathResolver` resolves: `paths` stats, `each` reads, `classes` names by the declared constant
118
- - **EagerLoad** - loads a directory's constants for a booted-tier walk, one file at a time, so an unloadable file costs only itself
118
+ - **EagerLoad** - loads a directory's constants for a booted-tier walk, one file at a time, so an unloadable file costs only itself. A file Prism finds a syntax error in is never required, nor is one whose class body names its constant (a subclass, an includer); each is left out with the reason, which a model's or a controller's answer gives in its place. A tool that looks an app class up by name goes through `EagerLoad.constant`, which loads it as `constantize` would and never requires such a file
119
119
  - **GemLock** - which gems the app resolved and at what version, read once per lockfile and matched by exact name across GEM, GIT and PATH
120
120
  - **PackageJson** - which npm packages the app depends on, read once per `package.json`: present means named in `dependencies` or `devDependencies`, so an `overrides` pin is not a dependency, and `@tailwindcss/vite` counts as tailwindcss
121
121
 
@@ -170,7 +170,7 @@ rails-ai-context://views/posts/show.html.erb → template content
170
170
  rails-ai-context://routes/posts → filtered routes
171
171
  ```
172
172
 
173
- Every resolve call introspects fresh - zero stale data.
173
+ Every resolve call introspects afresh, once the server has reloaded the app's code for any edit (see Caching).
174
174
 
175
175
  ### Hydration Layer (`lib/rails_ai_context/hydrators/`)
176
176
 
@@ -213,7 +213,8 @@ Result: controller and view tools automatically include relevant schema informat
213
213
 
214
214
  Thor-based CLI that works standalone (no Gemfile entry):
215
215
 
216
- - `EntryBoot` - Finds and boots the host Rails app for the standalone binary, or says which tier it fell back to
216
+ - `AppRoot` - Decides which app a command reads before anything reads the working directory: `--app-path` as given, else the working directory, else the nearest app root above it, else the one app below it (several are refused by name, and `init` sets them all up)
217
+ - `EntryBoot` - Boots the app `AppRoot` chose for the standalone binary, or says which tier it fell back to
217
218
  - `ToolRunner` - Parses CLI args, resolves tool names, executes tools, formats output
218
219
  - Supports `--json` mode for machine-readable output
219
220
  - Same 45 tools available as MCP and CLI
@@ -232,7 +233,7 @@ Four cache layers:
232
233
  3. **Run cache** (`RunCache`) - thread-local, lives for one `Introspector#call` or `generate_context`: file lists, stats and directory answers every section would otherwise ask again
233
234
  4. **Session cache** (`BaseTool.SESSION_CONTEXT`) - Mutex-protected call history, resets on server restart
234
235
 
235
- `LiveReload` watches files and calls `reset_all_caches!` when changes are detected.
236
+ Every server - stdio, HTTP, or the endpoints mounted in the app - checks the files at the start of each tool call and resource read (`BaseTool.refresh_if_files_changed!`, through `Fingerprinter::Snapshot`), reloading the app's code and resetting the caches when they moved, so an answer sees every edit made before its call. That check runs outside the introspection cache's mutex, since a code reload waits for running calls, and a call shares only a check that began after it arrived. `LiveReload` watches the same files and tells clients when they change; it loads no code and drops no cache.
236
237
 
237
238
  ### Fingerprinter (`lib/rails_ai_context/fingerprinter.rb`)
238
239
 
@@ -241,6 +242,7 @@ SHA256-based change detection:
241
242
  - Watches: `app/`, `config/`, `db/`, `lib/`, `rakelib/`, `test/`, `spec/`, the Gemfile and Gemfile.lock (or `gems.rb` and `gems.locked`), `package.json`, `tsconfig.json`, `config.ru`, the Rakefile
242
243
  - Computes a composite fingerprint from all watched files
243
244
  - Used by introspection cache and live reload to detect actual changes
245
+ - `Fingerprinter::Snapshot` answers the same question before every call by stat alone, keeping each directory's listing while the directory's stat holds; a file young enough to have been written twice in one clock tick has its content compared too, and a directory linked in from the app's repository is followed as the readers follow it, its link asked where it leads at every look
244
246
 
245
247
  ## Key design decisions
246
248
 
data/docs/CLI.md CHANGED
@@ -40,6 +40,10 @@ rails-ai-context serve --transport http --port 6029 # HTTP transport
40
40
  | `--port` | `http_port` from config, else `6029` | HTTP listen port |
41
41
  | `--no-boot` | off | Skip booting the app; answer from source alone |
42
42
 
43
+ Ctrl-C or SIGTERM stops the HTTP server and exits 0. Requests still running get 3 seconds, then Puma's own grace period of 5, before they are cut off, and the process exits within 15 seconds of the signal whatever else is still running.
44
+
45
+ The server announces itself to the client as `server_name` from config, else `rails-ai-context`. The `RAILS_AI_CONTEXT_SERVER_NAME` environment variable takes the place of that default for one process; a `server_name` the app sets itself still wins. A [workspace](#init-standalone-only) entry sets the variable to put its app first, since VS Code names every tool after the announced name and keeps 13 characters of it.
46
+
43
47
  ### `tool`
44
48
 
45
49
  Run any of the 45 MCP tools from the terminal.
@@ -67,7 +71,18 @@ Global options may be typed before or after the command name:
67
71
  `rails-ai-context --app-path /srv/app doctor` and
68
72
  `rails-ai-context doctor --app-path /srv/app` run the same check.
69
73
 
70
- `--app-path` names the Rails root, and defaults to the working directory.
74
+ `--app-path` names the Rails root, taken as given. Left out, every command
75
+ that reads an app finds it the way `bin/rails` and Bundler do: the working
76
+ directory if it is an app, else the nearest app root above it (a directory with
77
+ `config/application.rb`, `config/environment.rb`, or a `bin/rails` that boots an
78
+ app or an engine; never `$HOME`, `node_modules` or a gem install), else the one
79
+ app a level or two below it - looked for everywhere but `/`, `$HOME` and the
80
+ directories above it, which hold too much to search. A walk names the app it chose on stderr, as
81
+ `[rails-ai-context] using app at a/`, so `serve` keeps stdout clean. A folder
82
+ holding several apps is refused with one `--app-path` command per app, and
83
+ `tool --list` there lists the tools without an app. The chosen root is the
84
+ process's working directory for the rest of the run, so `init`, the config and
85
+ every file written land in it.
71
86
  `--environment` names the `RAILS_ENV` to boot under. Left out, it resolves the
72
87
  way Rails resolves its own environment: the ambient `RAILS_ENV`, then
73
88
  `RACK_ENV`, then `development`, reading an empty value as unset. So an app
@@ -114,7 +129,13 @@ from source alone, which is what you want on a repo you have just cloned, on an
114
129
  app whose boot is broken, and in CI where booting costs more than the answer.
115
130
 
116
131
  The same tier is entered automatically when a boot fails, so you get an answer
117
- either way. Answers that need a running app are marked `[UNAVAILABLE: ...]`
132
+ either way. A boot that hangs (an initializer waiting on a service that is
133
+ down) counts as failed after `RAILS_AI_CONTEXT_BOOT_TIMEOUT` seconds: 20 for
134
+ `serve` over stdio, whose client waits about 30 seconds for its first answer
135
+ and then gives up, and 60 for every other command. Raise it for an app that
136
+ is healthy but slow to boot, and for stdio also raise the client's own limit
137
+ (Claude Code reads `MCP_TIMEOUT`, in milliseconds), or the client stops
138
+ waiting first. Answers that need a running app are marked `[UNAVAILABLE: ...]`
118
139
  rather than guessed, and everything else is tagged `[STATIC]` instead of
119
140
  `[VERIFIED]`. `docs/COMPATIBILITY.md` lists which of the 40 introspectors answer
120
141
  in which tier.
@@ -161,6 +182,8 @@ rails-ai-context context --format claude # Specific format
161
182
  |:-------|:--------|:------------|
162
183
  | `--format` | all configured | `claude`, `cursor`, `copilot`, `opencode`, `codex`, `json`, `all` |
163
184
 
185
+ Without `--format`, `rails ai:context` and `rails-ai-context context` also bring the selected tools' MCP configs up to date, in the command form the install takes now - after `bundle remove rails-ai-context` that is the bare binary. A tool whose config a [workspace](#init-standalone-only) above already serves is left to it. `rails ai:context:<tool>` writes that tool's config along with its files.
186
+
164
187
  The rake tasks `ai:context`, `ai:context:<format>` and `ai:context_for` also read
165
188
  `CONTEXT_MODE`: `CONTEXT_MODE=full rails ai:context` overrides `config.context_mode`
166
189
  for that run. The standalone binary does not read it.
@@ -176,7 +199,11 @@ rails-ai-context doctor --strict # exit 1 when any check fails (CI gate)
176
199
  rails ai:doctor STRICT=1 # rake equivalent
177
200
  ```
178
201
 
179
- Checks include: schema existence, pending migrations, model files, routes, MCP config validity, introspector health, ripgrep availability, Prism gem, Brakeman gem, listen gem, gitignore security, auto_mount security, schema size, view count, and more.
202
+ Checks include: schema existence, pending migrations in every database (and a database that does not exist or does not answer), model files, routes, whether the command each MCP config holds can start, the Codex env snapshot, whether a context run would rewrite the context files, introspector health, ripgrep availability, the Prism and mcp gems, Brakeman gem, listen gem, secret files and .gitignore, auto_mount security, schema size, view count, and more. The [Guide](GUIDE.md#diagnostics) lists every check.
203
+
204
+ At a mountable engine's root, which boots no app of its own, `bundle exec rails-ai-context doctor` (or `rails-ai-context doctor` for a standalone install) diagnoses the dummy app the engine's `bin/rails` boots - `test/dummy` or `spec/dummy` - and reads the install at the engine's root: its `.rails-ai-context.yml`, MCP configs and context files, the ones an editor opened there uses. `bin/rails app:ai:doctor` reads them there too. Its fixes name the commands that run at the engine's root, such as `bundle exec rails-ai-context context`, and its context check runs that command into a copy of the files to see which it would rewrite.
205
+
206
+ Every path the report names is named from where the command was typed, and every command a fix gives runs there. Typed in an app's parent folder (`rails-ai-context --app-path shop doctor`), the app's files are named `shop/...`, and a command of the app's goes there first: `rails-ai-context --app-path shop context`, `cd shop && bin/rails db:migrate`, `git -C shop rm --cached config/master.key`. A line for `.gitignore` names the app's own and is written the way that file reads it, from the app's root: Add to shop/.gitignore: `.env.local`. Typed in a folder inside the app (`app/models`), the report names the app's files from its root, as the app names them, while a fix still works where it was typed: `cd ../.. && bin/rails db:migrate`, `git -C ../.. rm --cached .env`, `rails-ai-context --app-path ../.. context`, and Add to ../../.gitignore: `.env`. `rails ai:doctor` reads that folder from the shell's `PWD`, since Rails' `rails` command and rake move up to the app's root before the task runs. Typed at an engine's root, the dummy app's files read `test/dummy/db/schema.rb` and the engine's own `Gemfile.lock` and `app/views`, a command of the engine's runs "at the engine's root" (`bundle install` there needs no folder), and one of the dummy app's goes to `test/dummy` first; typed inside `test/dummy`, the engine's files read `../../Gemfile.lock` and its commands run "in the engine at ../..". A fix that starts with `cd` leaves the shell in that folder, so type the next one from where doctor ran.
180
207
 
181
208
  ### `watch`
182
209
 
@@ -187,7 +214,7 @@ rails ai:watch
187
214
  rails-ai-context watch
188
215
  ```
189
216
 
190
- Requires the `listen` gem. Watches `app/`, `config/`, `db/`, `lib/`, `rakelib/`, `test/`, `spec/`.
217
+ Requires the `listen` gem. `rails ai:watch` needs it in the app's Gemfile; the standalone binary uses the app's when its bundle locks one, and otherwise one installed with `gem install listen`. Watches `app/`, `config/`, `db/`, `lib/`, `rakelib/`, `test/`, `spec/`.
191
218
 
192
219
  ### `init` (standalone only)
193
220
 
@@ -198,7 +225,48 @@ rails-ai-context init
198
225
  rails-ai-context init --mcp-only # MCP config only, no context files
199
226
  ```
200
227
 
201
- Asks which AI tools to configure and what to write: MCP config and context files, context files only (CLI mode), or MCP config only. Creates `.rails-ai-context.yml`, the MCP config files (except in CLI mode) and the context files (except with MCP config only). `--mcp-only` skips the second question.
228
+ Asks which AI tools to configure and what to write: MCP config and context files, context files only (CLI mode), or MCP config only. Creates `.rails-ai-context.yml`, the MCP config files (except in CLI mode) and the context files (except with MCP config only). `--mcp-only` skips the second question. Every question - including a re-run's cleanup of dropped tools, and in CLI mode whether to take the gem's entries out of existing MCP configs - is asked before the app boots and anything is written; with MCP config only, the app is not booted at all.
229
+
230
+ Run inside an app, `init` sets up that app (found the way every command finds it: the [app root](#tool) above a subdirectory, or the one `--app-path` names).
231
+
232
+ Run in a folder that is no app but holds apps one or two levels down - a **workspace**, the folder an editor or agent is often opened at - it sets the folder up as a whole:
233
+
234
+ ```text
235
+ work/ <- run `rails-ai-context init` here
236
+ .mcp.json one server per app: rails-ai-context-api, rails-ai-context-web
237
+ .cursor/mcp.json ... (every selected AI tool's MCP config, in the folder it reads)
238
+ api/ .rails-ai-context.yml, CLAUDE.md, ... each app keeps its own config and context files
239
+ web/ .rails-ai-context.yml, CLAUDE.md, ...
240
+ ```
241
+
242
+ The AI tools and the setup are asked once. Each app gets its own `.rails-ai-context.yml` and context files, because its server reads them from its own root; a child process generates them with the install that app's server will run. The folder's MCP configs get one server per app, because a client reads its project config from the folder it opened. Each entry is named `rails-ai-context-<app folder>` (the whole path where two apps share a folder name; a short hash where the name would pass 30 characters, since Cursor drops a tool whose server and tool names together pass 60) and points `--app-path` at its app:
243
+
244
+ ```json
245
+ {
246
+ "mcpServers": {
247
+ "rails-ai-context-api": {
248
+ "command": "rails-ai-context",
249
+ "args": ["serve", "--app-path", "api"],
250
+ "env": { "RAILS_AI_CONTEXT_SERVER_NAME": "api-rails-ai-context" }
251
+ },
252
+ "rails-ai-context-web": {
253
+ "command": "bundle",
254
+ "args": ["exec", "rails-ai-context", "serve", "--app-path", "web"],
255
+ "env": { "BUNDLE_GEMFILE": "web/Gemfile", "RAILS_AI_CONTEXT_SERVER_NAME": "web-rails-ai-context" }
256
+ }
257
+ }
258
+ }
259
+ ```
260
+
261
+ - **Paths stay relative**, so a committed file means the same thing on every machine. Claude Code, Codex and OpenCode start a server in the folder they were launched in, so their configs name the app from the workspace (`api`). Cursor and VS Code have a name for the folder their config sits in, `${workspaceFolder}`, so their configs name the app from it (`${workspaceFolder}/api`); Cursor does not promise a working directory.
262
+ - **The command form is each app's own**: the bare binary where the gem is not in the app's Gemfile.lock, `bundle exec` where it is, with `BUNDLE_GEMFILE` naming the Gemfile the app's bundle reads - its own (a linked one by the link's name, so its lockfile stays beside it), else the one its `config/boot.rb` names, as a monorepo's apps share one, else the one `bundle exec` run inside the app would find - since `bundle exec` started in the workspace would look for one there.
263
+ - **`RAILS_AI_CONTEXT_SERVER_NAME` puts the app first** in the name the server announces. VS Code names every tool after that name and keeps 13 characters of it, so without it every app's tools would start `rails-ai-cont`. It is a variable rather than a flag so that an app whose bundle pins an older gem ignores it and starts.
264
+ - A bare `rails-ai-context` entry left in the folder is replaced, since it would serve the folder itself, and so is an entry under a name the gem gives an app in this folder (its folder name or path) when that app's folder is gone, or the app now goes by another name because an app with the same folder name arrived. An entry named `rails-ai-context-...` that runs `rails-ai-context serve` is the gem's: dropping an AI tool removes it from that tool's config. One that runs no command, such as an HTTP entry of your own under either name, is left alone, and so is a second entry you named yourself for an app (`rails-ai-context-api-2` included), one that names its app through a variable the tool expands (`${userHome}`), and one naming an app outside the folder.
265
+ - A config that does not parse as JSON, or holds comments a rewrite would drop, is left as it is, with a warning that names the entry to add by hand. Trailing commas, which VS Code's and OpenCode's take, are read under json 2.9 or later; Claude Code's `.mcp.json` and Cursor's `.cursor/mcp.json` are held to plain JSON, so a comment or trailing comma there is reported as one the client cannot read. An empty one is filled, and a byte order mark at the head of one stays.
266
+ - `--app-path` naming a folder of apps sets that folder up the same way; every other command lists the apps below it.
267
+ - An app whose bundle is not installed yet (a Gemfile and no lockfile) is served by this binary unless its Gemfile names the gem, or pulls gems in from other files (`eval_gemfile`, `gemspec`) that could.
268
+
269
+ Start the client in the workspace folder. A client started inside one of the apps (Claude Code, Codex and OpenCode still read a config above it) runs each workspace server from there, where the relative path misses; the server says which folder the path was written for. To open an app on its own, run `init` inside it as well. `init` warns when an app declares a Ruby other than the one the workspace runs, since every server it starts runs under that one. `doctor` inside one of the apps finds the workspace's config one or two levels up.
202
270
 
203
271
  ### `version`
204
272
 
@@ -239,7 +307,8 @@ rails 'ai:preset[architecture]'
239
307
  ### `tree`
240
308
 
241
309
  Print a tree of every command. `rails-ai-context help <command>` lists that
242
- command's options.
310
+ command's options, and so do `rails-ai-context <command> --help` and `-h`
311
+ (after a tool's name, `--help` is that tool's: `tool schema --help`).
243
312
 
244
313
  ```bash
245
314
  rails-ai-context tree
@@ -306,6 +375,10 @@ rails-ai-context tool schema --table users --json
306
375
  JSON=1 rails 'ai:tool[schema]' table=users
307
376
  ```
308
377
 
378
+ stdout then holds the envelope (`tool`, `output`, `error`) and nothing else.
379
+ Whatever the app prints while it boots or while the tool runs - an
380
+ initializer's `puts`, Sidekiq's Redis connection line - goes to stderr.
381
+
309
382
  ---
310
383
 
311
384
  ## Common workflows