rails-ai-context 5.32.2 → 5.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -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 +10 -8
- 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 +152 -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/app_kind.rb +18 -5
- 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 +58 -22
- 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 +18 -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 +186 -27
- 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_record_settings.rb +1 -1
- 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 +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- 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 +23 -10
- 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 +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- 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/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- 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_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- 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 +182 -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 +92 -8
- 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 +262 -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 +88 -33
- 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 +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -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 +447 -372
- 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 +367 -46
- 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 +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- 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 +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- 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 +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- 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 +215 -53
- 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 +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- 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 +14 -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.
|
|
@@ -56,7 +66,7 @@ The other half of the same problem is the reverse trip. Once a name is the decla
|
|
|
56
66
|
|
|
57
67
|
## Table name
|
|
58
68
|
|
|
59
|
-
The table a model reads. Rails builds it as `table_name_prefix` + the demodulized class name, pluralized unless `pluralize_table_names` is off, + `table_name_suffix`, unless the class assigns one itself or is an STI child, which reads its parent's. The static tier answers the same order from source: an explicit `self.table_name =`, else the table of the model it inherits from, else the prefix and suffix the enclosing modules declare around the file's own basename. That basename is pluralized unless the class, then its superclass chain, then the app sets `pluralize_table_names = false`. The app-wide affixes and pluralize setting come from `ActiveRecordSettings`, which reads config/application.rb, the environment file, the initializers and `on_load(:active_record)` blocks. Never the underscored constant - that turns `Admin::ActionLog` into `admin/action_logs` and `OAuthClientConfig` into `o_auth_client_configs`, neither of which is a table.
|
|
69
|
+
The table a model reads. Rails builds it as `table_name_prefix` + the demodulized class name, pluralized unless `pluralize_table_names` is off, + `table_name_suffix`, unless the class assigns one itself or is an STI child, which reads its parent's. The static tier answers the same order from source: an explicit `self.table_name =`, else the table of the model it inherits from, else the prefix and suffix the enclosing modules declare around the file's own basename. That basename is pluralized unless the class, then its superclass chain, then the app sets `pluralize_table_names = false`. A class on a parent the app does not define (`Tag < ActsAsTaggableOn::Tag`) reads the parent's table, which the static tier takes from the schema: the table it holds among the parent's names and the class's own. The app-wide affixes and pluralize setting come from `ActiveRecordSettings`, which reads config/application.rb, the environment file, the initializers and `on_load(:active_record)` blocks. Never the underscored constant as an answer - that turns `Admin::ActionLog` into `admin/action_logs` and `OAuthClientConfig` into `o_auth_client_configs`, neither of which is a table; a gem parent's underscored name is only a candidate the schema must hold.
|
|
60
70
|
|
|
61
71
|
`TableName` reads the five declarations of one class body (`table_name`, `table_name_prefix`, `table_name_suffix`, `pluralize_table_names` and `primary_key`) and holds the two derivations; walking the namespace and the superclass chain belongs to the caller, because only it has the other files. A reader that starts from a model name instead of a file asks `TableName.for_model_name`, which takes the table the model tier already recorded before falling back to the convention. A module prefix declared outside the model directories is still invisible, an engine's in `lib/**/engine.rb` aside, so the answer stays [STATIC].
|
|
62
72
|
|
|
@@ -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
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
*Give your AI coding assistant ground truth about your Rails app*
|
|
6
6
|
|
|
7
|
-
[](https://rubygems.org/gems/rails-ai-context)
|
|
8
|
-
[](https://rubygems.org/gems/rails-ai-context)
|
|
9
|
-
[](https://rubygems.org/gems/rails-ai-context)
|
|
8
|
+
[](https://rubygems.org/gems/rails-ai-context)
|
|
9
|
+
[](https://github.com/crisnahine/rails-ai-context/actions/workflows/ci.yml)
|
|
10
10
|
[](https://registry.modelcontextprotocol.io)
|
|
11
11
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
12
12
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
@@ -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,35 @@ 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
|
-
# Class-level memoization - transport persists across requests.
|
|
134
|
-
# Thread-safe: MCP::Server and transport are stateless for reads.
|
|
135
172
|
def mcp_transport
|
|
136
|
-
|
|
173
|
+
McpEdge.engine_transport
|
|
137
174
|
end
|
|
138
175
|
|
|
139
176
|
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)
|
|
177
|
+
McpEdge.reset_engine_transport!
|
|
148
178
|
end
|
|
149
179
|
end
|
|
150
|
-
|
|
151
|
-
@transport_mutex = Mutex.new
|
|
152
180
|
end
|
|
153
181
|
end
|
data/config/routes.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
RailsAiContext::Engine.routes.draw do
|
|
4
|
-
# The
|
|
5
|
-
|
|
6
|
-
match "/", to: "rails_ai_context/mcp#handle", via: :all
|
|
4
|
+
# The class, not "rails_ai_context/mcp", which the router camelizes with the app's inflections.
|
|
5
|
+
match "/", to: RailsAiContext::McpController.action(:handle), via: :all
|
|
7
6
|
end
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -26,7 +26,7 @@ graph TD
|
|
|
26
26
|
subgraph engine["Introspection Engine"]
|
|
27
27
|
direction LR
|
|
28
28
|
I["Introspectors\n40 modules\nPresets\nCached"]
|
|
29
|
-
AST["AST Engine\nPrism\
|
|
29
|
+
AST["AST Engine\nPrism\n45 listeners\nConfidence tags"]
|
|
30
30
|
H["Hydration Layer\nSchema hints\ninjected into\ntool responses"]
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -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
|
|