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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1287 -0
- data/CONTEXT.md +26 -2
- data/README.md +13 -9
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +114 -84
- data/docs/ARCHITECTURE.md +6 -4
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +65 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +8 -7
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +145 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +53 -20
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +11 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +176 -26
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +10 -3
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +56 -8
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +14 -2
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +2 -1
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +46 -1
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +3 -1
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +14 -3
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -2
- data/lib/rails_ai_context/introspectors/model_introspector.rb +91 -5
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +57 -4
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +13 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +130 -11
- data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +185 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +55 -7
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +254 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +86 -32
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +17 -15
- data/lib/rails_ai_context/serializers/guide_examples.rb +141 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +142 -29
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +36 -14
- data/lib/rails_ai_context/serializers/section_facts.rb +35 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +112 -43
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +352 -41
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +157 -40
- data/lib/rails_ai_context/tools/get_conventions.rb +26 -4
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +9 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +2 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +16 -6
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +19 -6
- data/lib/rails_ai_context/tools/get_schema.rb +51 -18
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +46 -6
- data/lib/rails_ai_context/tools/performance_check.rb +4 -2
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +204 -43
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +3 -3
- data/lib/rails_ai_context/tools/security_scan.rb +141 -31
- data/lib/rails_ai_context/tools/session_context.rb +38 -4
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +129 -55
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- 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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 =
|
|
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
|
-
|
|
65
|
-
self.response_body = chunks.join
|
|
118
|
+
chunks.join
|
|
66
119
|
elsif body.respond_to?(:call)
|
|
67
|
-
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
|
-
|
|
88
|
-
response.stream.close
|
|
89
|
-
rescue StandardError
|
|
90
|
-
nil
|
|
91
|
-
end
|
|
124
|
+
stream.close
|
|
92
125
|
end
|
|
126
|
+
stream.string
|
|
93
127
|
else
|
|
94
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
#
|
|
134
|
-
#
|
|
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
|
-
|
|
175
|
+
McpEdge.engine_transport
|
|
137
176
|
end
|
|
138
177
|
|
|
139
178
|
def reset_transport!
|
|
140
|
-
|
|
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
|
|
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
|
-
- `
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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
|