rails-ai-context 5.15.0 → 5.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +222 -0
  3. data/README.md +3 -2
  4. data/config/routes.rb +3 -1
  5. data/docs/CUSTOM_TOOLS.md +17 -3
  6. data/docs/TROUBLESHOOTING.md +17 -0
  7. data/exe/rails-ai-context +50 -7
  8. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
  9. data/lib/rails_ai_context/app_kind.rb +12 -3
  10. data/lib/rails_ai_context/cli/tool_runner.rb +1 -1
  11. data/lib/rails_ai_context/doctor.rb +121 -3
  12. data/lib/rails_ai_context/introspector.rb +4 -1
  13. data/lib/rails_ai_context/introspectors/controller_introspector.rb +16 -1
  14. data/lib/rails_ai_context/introspectors/job_introspector.rb +19 -1
  15. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +3 -0
  16. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +21 -4
  17. data/lib/rails_ai_context/introspectors/model_introspector.rb +28 -4
  18. data/lib/rails_ai_context/introspectors/route_introspector.rb +22 -0
  19. data/lib/rails_ai_context/introspectors/schema_introspector.rb +158 -5
  20. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +15 -2
  21. data/lib/rails_ai_context/path_resolver.rb +6 -0
  22. data/lib/rails_ai_context/safe_file.rb +5 -1
  23. data/lib/rails_ai_context/schema_version.rb +9 -4
  24. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  25. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +20 -9
  26. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -1
  27. data/lib/rails_ai_context/serializers/copilot_serializer.rb +13 -2
  28. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
  29. data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
  30. data/lib/rails_ai_context/serializers/section_guard.rb +16 -0
  31. data/lib/rails_ai_context/server.rb +49 -7
  32. data/lib/rails_ai_context/source_line.rb +208 -0
  33. data/lib/rails_ai_context/test_helper.rb +6 -1
  34. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -4
  35. data/lib/rails_ai_context/tools/base_tool.rb +16 -1
  36. data/lib/rails_ai_context/tools/diagnose.rb +7 -3
  37. data/lib/rails_ai_context/tools/generate_test.rb +12 -0
  38. data/lib/rails_ai_context/tools/get_config.rb +6 -0
  39. data/lib/rails_ai_context/tools/get_conventions.rb +28 -8
  40. data/lib/rails_ai_context/tools/get_edit_context.rb +1 -1
  41. data/lib/rails_ai_context/tools/get_env.rb +3 -0
  42. data/lib/rails_ai_context/tools/get_frontend_stack.rb +4 -4
  43. data/lib/rails_ai_context/tools/get_job_pattern.rb +19 -6
  44. data/lib/rails_ai_context/tools/get_model_details.rb +15 -3
  45. data/lib/rails_ai_context/tools/get_routes.rb +8 -0
  46. data/lib/rails_ai_context/tools/get_schema.rb +24 -1
  47. data/lib/rails_ai_context/tools/get_service_pattern.rb +9 -1
  48. data/lib/rails_ai_context/tools/get_turbo_map.rb +27 -8
  49. data/lib/rails_ai_context/tools/query.rb +4 -0
  50. data/lib/rails_ai_context/tools/runtime_info.rb +4 -0
  51. data/lib/rails_ai_context/tools/search_code.rb +1 -1
  52. data/lib/rails_ai_context/tools/search_docs.rb +2 -2
  53. data/lib/rails_ai_context/version.rb +1 -1
  54. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0250405dffbfbb2100bf513cc75840ae980f68970e1a76911c4b2cc5ba3fac77
4
- data.tar.gz: 25606a4264641a0a1be621bb30f3476c9ce1ecc1fd8dc37f7719ab15acee5e14
3
+ metadata.gz: 59c335a48ab2976318ed2fc58d1cce4e2153c0db0144b61ca8d902f160bbeb76
4
+ data.tar.gz: 334cc340093515797006c8326b47fa97721c9e66f07f940f3ec0864db2ae241a
5
5
  SHA512:
6
- metadata.gz: 88b6c43ddb2d47e8c7d428e0188fc9060584252d7457bcd5d5b580cba6029a73f383684582d04228c79aee1a5c014f5d65494439697e718d38844900a3b5890a
7
- data.tar.gz: 00e2c762d693c804f0260b5e0d054ffb0443afd8271299a8711638f23b7a6aeaeaee68ea833ed788225086fc38ddb289b5de562fb3e5379d78e8a84cd6c75f3b
6
+ metadata.gz: f0e5532e52f873a3cb659dbf90fde582974f4840609f0c8365318f5b862659475484232e9ead3fd0074ec1a079db565cd8046a157ceb4f1bc1099b08877feded
7
+ data.tar.gz: e6bd4fe02b30344a494a8af944ab6ca94e07f944d143d99bd4f116ca3f15099a5fbbe840b38c1b186398c43c3b23825889fd4b852593fc09342edc50910cd4e9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,228 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.16.0] - 2026-07-12
9
+
10
+ ### Fixed
11
+
12
+ - **HTTP engine mount works again.** `mount RailsAiContext::Engine, at: "/mcp"`
13
+ 500'd with `uninitialized constant McpController`: the engine is not
14
+ namespace-isolated, so its route needed the fully-qualified
15
+ `rails_ai_context/mcp#handle` controller path. Verified with a real curl
16
+ MCP session (initialize + session-id + tools/call) against a booted app.
17
+ - **`config.custom_tools` is usable as documented.** Referencing an
18
+ `app/mcp_tools/` class constant in the initializer aborted boot with
19
+ `NameError` (app/ constants are not autoloadable while initializers run),
20
+ and a class-name string crashed every CLI tool invocation with
21
+ `undefined method 'tool_name' for an instance of String`. Entries may now
22
+ be classes or class-name strings; strings resolve lazily after boot, and
23
+ invalid entries are warn-skipped everywhere (server, CLI runner,
24
+ TestHelper) instead of taking down unrelated tools. Docs and the
25
+ initializer template now show the string form.
26
+ - **Mailers are no longer invisible in development.** `extract_mailers` read
27
+ `ActionMailer::Base.descendants` without the eager-load fallback that
28
+ channels already had, so every dev-mode run reported zero mailers
29
+ (`ai:inspect`, context files, onboard).
30
+ - **TestHelper is self-sufficient.** `require "rails_ai_context/test_helper"`
31
+ alone raised `uninitialized constant RailsAiContext::Tools` - the common
32
+ case, since a `group: :development` install is never Bundler-required in
33
+ the test environment. The helper now requires the gem core itself; docs
34
+ explain custom-tool name lookup in the test environment.
35
+ - **`claude_max_lines` counts physical lines.** Dual MCP/CLI tool examples
36
+ embed newlines inside single entries, so a default-capped CLAUDE.md could
37
+ ship 161 real lines with no trim marker. The cap (and the trim) now apply
38
+ to physical lines of the managed block.
39
+ - **turbo_map no longer flags correct wiring.** `broadcast_append_to post`
40
+ against `turbo_stream_from @post` warned "no matching turbo_stream_from";
41
+ stream matching now strips the ivar sigil before comparing.
42
+ - **frontend_stack's Turbo wiring line can actually render.** It read
43
+ `turbo[:broadcasts]`/`[:frames]` - keys the introspector never emits
44
+ (`model_broadcasts`/`turbo_frames`) - so the line was dead code for every
45
+ app; callback-style `broadcast_*_to` calls are now also detected.
46
+ - **generate_test emits runnable tests for arg-taking scopes.** A scope like
47
+ `scope :by_status, ->(s) { ... }` produced `Post.by_status` (ArgumentError
48
+ at runtime); scope lambdas' required params are now captured through the
49
+ AST layer and such scopes get an `assert_respond_to` test with a
50
+ fill-in-the-args call site. Verified by running the generated file for real.
51
+ - **job_pattern keeps `wait:` in retry configuration.** One ordered regex
52
+ dropped whichever of `wait:`/`attempts:` came first in `retry_on`; the two
53
+ options are now scanned independently.
54
+ - **get_env skips comment lines.** `# ENV["WEB_CONCURRENCY"]` in the stock
55
+ puma.rb comment was reported as a real environment variable.
56
+ - **diagnose omits an empty "Next Steps" section** instead of rendering a
57
+ bare header when no file/method context was parsed.
58
+ - **analyze_feature lists jbuilder templates and record renders**
59
+ (`render @post` / `render post`), matching what get_view reports.
60
+ - **service_pattern surfaces mailer dependencies** (`PostMailer.published_email`
61
+ style calls) and recognizes `perform_now`.
62
+ - **README observability example reads `event.payload[:duration]`** - the
63
+ bridge emits a zero-width event, so `event.duration` is always ~0ms.
64
+ - Singular/plural agreement in tool output ("1 result", "1 site",
65
+ "1 broadcast", "1 line", "1 table", "1 controller", "1 migration file").
66
+ - edit_context's footer no longer suggests pasting the line-numbered block
67
+ verbatim as an Edit old_string.
68
+ - **A syntax-broken model or controller file no longer kills the process in
69
+ the runtime tier.** Eager loading aborts at the first bad file with a
70
+ `SyntaxError` - a `ScriptError`, which sailed past every bare `rescue` and
71
+ took down the MCP server mid-session and every model-touching rake task.
72
+ Introspection now rescues `ScriptError` at the dispatch level, eager
73
+ loading falls back to per-constant loading, and `discover_models`
74
+ tolerates unloadable classes - one broken file costs exactly that file,
75
+ matching the static tier's contract.
76
+ - **Standalone CLI no longer mixes framework versions.** The RubyGems
77
+ binstub activates the newest installed railties before the app's
78
+ Gemfile.lock pins its own, leaving newer framework paths in $LOAD_PATH:
79
+ every generated fact reported the wrong Rails version (e.g. 8.1.3 for an
80
+ 8.0.5 app) with "already initialized constant" warnings polluting all
81
+ output. The exe now strips Rails-family gem paths at startup; the app's
82
+ bundle is the only framework source.
83
+ - **`--app-path` and `--environment` work after the tool name** (`tool
84
+ schema --table notes --app-path /x`), matching the `--json`/`--no-boot`
85
+ passthrough handling. Previously they were silently discarded and the
86
+ command ran against the wrong directory.
87
+ - **structure.sql parsing: single-line CREATE TABLE statements** (the shape
88
+ sqlite's `.schema` emits) no longer lose every column but the first;
89
+ bodies are split on top-level commas before per-line parsing.
90
+ - **structure.sql parsing: foreign keys land on the right table.** The
91
+ `ALTER TABLE ... FOREIGN KEY` regex could span statement boundaries and
92
+ attribute a FK to whichever table had the first `ADD CONSTRAINT`.
93
+ - **structure.sql parsing: NOT NULL columns are no longer reported as
94
+ nullable** (nullability was simply not extracted).
95
+ - **Runtime-only tools refuse in the static tier.** After a mid-boot
96
+ failure, `runtime_info` and `query` ran against the half-initialized app
97
+ and returned live, unmarked data contradicting the static-tier banner in
98
+ the same response; both now answer `[UNAVAILABLE: static tier]` with the
99
+ boot-failure reason. This also removes a raw NameError leak in
100
+ runtime_info's Cache section and query's wrong `--skip-active-record`
101
+ blame under `--no-boot`.
102
+ - **The static-tier banner carries the literal `[STATIC]` tag** that README
103
+ and COMPATIBILITY.md promise.
104
+ - **doctor's "Secrets in .gitignore" check understands globs.** Rails 8.1
105
+ generates `/config/*.key`; the substring check false-failed every default
106
+ 8.1 app and docked its readiness score.
107
+ - **model_details carries confidence tags**: `[VERIFIED]` on the runtime
108
+ model header, per-scope `[VERIFIED]`/`[INFERRED]` from the AST layer
109
+ (README promised tags this tool never rendered).
110
+ - **Route stats agree across generated context files.** CLAUDE.md counted
111
+ all routes over app controllers while copilot-instructions counted all
112
+ routes over all controllers ("35 across 1" vs "35 across 18"); every
113
+ serializer now reports app routes across app controllers with the
114
+ framework-inclusive total alongside.
115
+ - **get_routes counts engine mounts.** Verbless rack mounts (propshaft's
116
+ `/assets`) vanished from both the app list and the excluded-framework
117
+ count; the header now reports them explicitly.
118
+ - **Mongoid apps are detected from the Gemfile too** (previously only
119
+ Gemfile.lock or config/mongoid.yml), so a fresh checkout without a
120
+ lockfile gets honest Mongoid treatment instead of ActiveRecord answers.
121
+ - **`extra_app_paths` accepts entries ending in `/app`** (`custom/app`
122
+ previously globbed `custom/app/app/models` and silently missed).
123
+ - **analyze_feature no longer invents model names** ("user, payment, order")
124
+ on apps with zero models.
125
+ - **A missing schema is "unavailable", not "failed".** A greenfield app's
126
+ `context` output framed the absent db/schema.rb as "introspection failed";
127
+ absent data sources now use the `[UNAVAILABLE]` vocabulary.
128
+ - **conventions: `create!` flows are no longer attributed the save-and-branch
129
+ skeleton**, zero-signal test files are no longer credited as pattern
130
+ sources, and empty custom-directory descriptions drop the dangling dash.
131
+ - **get_config names the environment its values reflect** (cache store,
132
+ queue adapter, and Action Cable differ per environment).
133
+ - **get_env skips ERB-only false positives from comments** and standalone
134
+ `doctor` writes its report to stdout (`doctor > report.txt` works).
135
+ - docs: TROUBLESHOOTING covers RubyGems' "Resolving dependencies..." stdout
136
+ pollution of the MCP stdio stream and its fixes; a new doctor check ("MCP
137
+ stdio hygiene") detects the condition by re-running gem activation the way
138
+ an MCP client's binstub launch does.
139
+ - **Static schema answers include the implicit id primary key**, typed per
140
+ the adapter in config/database.yml (bigint everywhere, integer on SQLite);
141
+ composite-primary-key tables (`primary_key: [...]`) are left to their
142
+ explicitly dumped columns instead of gaining a phantom id.
143
+ - **Static schema answers name their source dialect and migration state**:
144
+ structure.sql parses report "Dialect", "Schema version" (from the
145
+ schema_migrations insert tail), and the exact pending migration files;
146
+ schema.rb parses report version plus files newer than it. Secondary
147
+ database dumps compare against their own db/<name>_migrate directories.
148
+ - Review-round regression fixes (found by adversarial review of this batch):
149
+ the missing-schema `{unavailable:}` result no longer leaks blank
150
+ "- Database:" lines into generated context files; the gitignore matcher
151
+ honors git's basename rule (`*.key`, bare `master.key`); multi-line
152
+ `retry_on` declarations keep their continuation-line options; a
153
+ syntax-broken `custom_tools` string entry warn-skips instead of crashing
154
+ the server and CLI; the Turbo broadcast scan ignores comments and method
155
+ definitions; redirect and lambda routes are not counted as engine mounts.
156
+ - Second review-round fixes: the gitignore security check honors `!`
157
+ negation patterns (a re-included secret now correctly fails the check);
158
+ `SafeFile.read` scrubs invalid UTF-8 so one bad byte in one file can no
159
+ longer raise out of any scan (previously it could erase every model's
160
+ Turbo broadcasts, or crash `get_turbo_map` outright); legacy zero-padded
161
+ migration versions (`001_` storing version "1") no longer read as
162
+ forever-pending; the synthesized primary-key type is resolved per
163
+ database from config/database.yml (a mixed postgres/sqlite multi-db app
164
+ types each dump correctly); endless-method broadcasts
165
+ (`def refresh = broadcast_replace_to(...)`) are detected while trailing
166
+ `#comments` (with or without spaces) are not, in both the introspector
167
+ and get_turbo_map's own scanners; `retry_on` options inside trailing
168
+ comments are not reported as real retry config; the doctor stdio-hygiene
169
+ probe launches from Bundler's original environment (a configured bundle
170
+ path no longer produces a spurious warning); a custom tool whose class
171
+ body raises during autoload is reported as broken rather than
172
+ "class not found"; the schema_migrations INSERT regex and the
173
+ section-usability predicate each have a single home.
174
+ - Third review-round fixes: source-line scans (Turbo broadcasts, retry_on
175
+ options) strip comments with a string-aware walker (`SourceLine`) instead
176
+ of a regex, so a `#` inside a string literal (`"#comments"`, `"##{id}"`)
177
+ no longer truncates the line and drops real calls, while comment text
178
+ still never reads as code - including comments after a continuation comma
179
+ in multi-line `retry_on`; `def self.broadcast_*_to` definitions are not
180
+ counted as broadcast calls; the doctor gitignore check now models git's
181
+ negation semantics (dir-only `!*/` entries, no re-inclusion under an
182
+ excluded parent, negations must match the file itself) - verified against
183
+ `git check-ignore` on a 16-case battery; the database.yml block lookup
184
+ no longer bleeds across whitespace-only lines into a sibling database's
185
+ adapter; and a custom tool whose body references a missing constant that
186
+ shares a name segment with the entry is no longer misreported as
187
+ "class not found".
188
+ - Fourth review-round fixes: the gitignore check now evaluates paths the
189
+ way git does (ancestor-directory recursion with last-match-wins instead
190
+ of descend globbing), closing a false-SAFE case where `config/` followed
191
+ by `!config/` read as still covering config/master.key - verified 25/25
192
+ against `git check-ignore`, including `**/`, whitelist preludes, and
193
+ re-included directories; it also honors case-insensitive filesystems the
194
+ way core.ignorecase does. `SourceLine.strip_comment` gained %-literal
195
+ support (`%w[cs#billing]` is content, `n % 2` is not a literal), an
196
+ O(n) walk (a 400k-char multibyte line dropped from 27s to 51ms), and a
197
+ no-`#` fast path. Broadcast def-blanking handles constant receivers
198
+ (`def User.broadcast_updates_to`) and multiple defs per line; multi-line
199
+ broadcast extraction reads comment-stripped context; `::`-prefixed
200
+ custom_tools entries classify correctly; config/database.yml is read
201
+ once, CRLF-normalized, per introspection run.
202
+ - Sixth-through-eighth review-round fixes (call-site scanning converged
203
+ after three more single-finding rounds): classic one-line methods
204
+ (`def x; broadcast_y_to(...); end`) contribute their body while
205
+ visibility-prefixed definitions (`private def`, `private_class_method
206
+ def`) contribute nothing; `;`/`=` inside string keyword defaults
207
+ (`sep: "; "`, `label: "a=b"`) and inside `#{...}` interpolation (with
208
+ nested quoted strings) no longer delimit a signature. A final review
209
+ round at a realistic-Rails bar returned zero findings.
210
+ - Fifth review-round fixes: comment stripping is now a whole-fragment
211
+ state machine (`SourceLine.strip_comments`) - literal state carries
212
+ across newlines, so a comment mid-way through a multi-line broadcast
213
+ call is removed while a string spanning lines keeps its tail; backtick
214
+ command literals (with interpolation) are modeled; singleton broadcast
215
+ definitions on any receiver (`def record.broadcast_status_to`,
216
+ `def self::...`) are excluded from broadcast detection; database.yml
217
+ without a final newline still resolves its last block's adapter; and
218
+ the case-insensitivity probe works for app roots with caseless names
219
+ (all-digit directories).
220
+
221
+ ### Changed
222
+
223
+ - Standalone `doctor` writes its report to stdout (previously stderr), so
224
+ `doctor > report.txt` captures it; update scripts that redirected `2>`.
225
+ - Custom tools resolved through the CLI are now validated as `MCP::Tool`
226
+ subclasses, matching what the MCP server has always enforced; duck-typed
227
+ tool classes that only ever worked via `rails-ai-context tool` are
228
+ warn-skipped with the reason.
229
+
8
230
  ## [5.15.0] - 2026-07-11
9
231
 
10
232
  ### Added
data/README.md CHANGED
@@ -523,7 +523,7 @@ class RailsGetBusinessMetrics < MCP::Tool
523
523
  end
524
524
 
525
525
  # config/initializers/rails_ai_context.rb
526
- config.custom_tools = [RailsGetBusinessMetrics]
526
+ config.custom_tools = ["RailsGetBusinessMetrics"]
527
527
  ```
528
528
 
529
529
  Test with the built-in `TestHelper` (works with RSpec and Minitest):
@@ -562,7 +562,8 @@ Every MCP tool call fires an `ActiveSupport::Notifications` event:
562
562
 
563
563
  ```ruby
564
564
  ActiveSupport::Notifications.subscribe("rails_ai_context.tools.call") do |event|
565
- Rails.logger.info "[MCP] #{event.payload[:method]} - #{event.duration}ms"
565
+ ms = (event.payload[:duration].to_f * 1000).round
566
+ Rails.logger.info "[MCP] #{event.payload[:tool_name]} - #{ms}ms"
566
567
  end
567
568
  ```
568
569
 
data/config/routes.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RailsAiContext::Engine.routes.draw do
4
- match "/", to: "mcp#handle", via: :all
4
+ # The engine is not namespace-isolated, so the controller must be
5
+ # referenced by its full path or Rails resolves a top-level McpController.
6
+ match "/", to: "rails_ai_context/mcp#handle", via: :all
5
7
  end
data/docs/CUSTOM_TOOLS.md CHANGED
@@ -75,13 +75,20 @@ Add your tool classes to the configuration:
75
75
  # config/initializers/rails_ai_context.rb
76
76
  if defined?(RailsAiContext)
77
77
  RailsAiContext.configure do |config|
78
- config.custom_tools = [RailsGetBusinessMetrics]
78
+ config.custom_tools = ["RailsGetBusinessMetrics"]
79
79
  end
80
80
  end
81
81
  ```
82
82
 
83
83
  Custom tools appear alongside built-in tools in the MCP server and CLI.
84
84
 
85
+ Use the class *name* as a string: classes under `app/` (including
86
+ `app/mcp_tools/`) are not autoloadable while initializers run, so a bare
87
+ constant reference there aborts boot with a `NameError`. String entries are
88
+ resolved after the app finishes booting. A bare constant still works for
89
+ classes already loaded when the initializer runs (e.g. defined in `lib/`
90
+ and required manually).
91
+
85
92
  ## Using BaseTool features
86
93
 
87
94
  For access to caching, pagination, and helper methods, you can use `RailsAiContext::Tools::BaseTool` utilities directly:
@@ -111,7 +118,14 @@ end
111
118
 
112
119
  ## Testing custom tools
113
120
 
114
- Use the built-in `TestHelper` module:
121
+ Use the built-in `TestHelper` module.
122
+
123
+ With the gem in `group: :development`, the initializer's guard makes
124
+ `config.custom_tools` a no-op in the test environment, so name-based lookup
125
+ (`execute_tool("rails_get_business_metrics")`) won't find custom tools
126
+ there. Either install the gem with `group: %i[development test]`, or pass
127
+ the class itself: `execute_tool(RailsGetBusinessMetrics)`. Built-in tools
128
+ resolve by name in any environment.
115
129
 
116
130
  ### With RSpec
117
131
 
@@ -191,7 +205,7 @@ If a custom tool replaces a built-in one, exclude the original:
191
205
 
192
206
  ```ruby
193
207
  RailsAiContext.configure do |config|
194
- config.custom_tools = [MyBetterSecurityScan]
208
+ config.custom_tools = ["MyBetterSecurityScan"]
195
209
  config.skip_tools = %w[rails_security_scan]
196
210
  end
197
211
  ```
@@ -100,6 +100,23 @@ For Codex CLI specifically, the env section in `.codex/config.toml` must match y
100
100
  2. Check schema exists: `ls db/schema.rb` or `ls db/structure.sql`
101
101
  3. Run doctor: `rails ai:doctor`
102
102
 
103
+ ### MCP client reports a JSON parse error on the first line ("Resolving dependencies...")
104
+
105
+ RubyGems can print `Resolving dependencies...` to stdout while activating the
106
+ standalone `rails-ai-context` binary - before any gem code runs - which breaks
107
+ the pure-JSON stdio framing. This happens when gem activation needs the full
108
+ dependency resolver: typically a `GEM_PATH` that is missing part of the
109
+ dependency graph, or launching from inside another Bundler project's
110
+ directory. Fixes:
111
+
112
+ 1. Launch `rails-ai-context serve` with the app root as the working directory
113
+ (the generated MCP configs do this by default).
114
+ 2. Make sure `GEM_PATH` covers the complete default gem path
115
+ (`gem env path`), not just `gem env gemdir` - Ruby's bundled gems
116
+ (racc, etc.) live in a separate directory.
117
+ 3. In-Gemfile installs are immune: `bundle exec rails-ai-context serve`
118
+ activates through the lockfile.
119
+
103
120
  ### MCP server responds slowly
104
121
 
105
122
  - Check `config.cache_ttl` - lower values mean more frequent re-introspection
data/exe/rails-ai-context CHANGED
@@ -11,6 +11,24 @@
11
11
  # correctly, and clearing specs breaks gems that look up their own path at runtime
12
12
  # (e.g., MCP SDK reads Gem.loaded_specs["json-schema"].full_gem_path).
13
13
  if defined?(Gem) && Gem.respond_to?(:loaded_specs) && !ENV["BUNDLE_BIN_PATH"]
14
+ # Also strip the Rails-family gems' lib paths. Leaving them in $LOAD_PATH
15
+ # lets the app's boot resolve framework files from the NEWEST installed
16
+ # versions instead of the app's Gemfile.lock pins, producing a mixed
17
+ # framework tree: wrong Rails.version in every generated fact and
18
+ # "already initialized constant" double-load warnings. Every Rails app's
19
+ # bundle provides these; in app-less modes a require re-activates them
20
+ # fresh. Non-framework tooling deps (mcp and friends) must keep their
21
+ # paths - Bundler.setup during app boot locks out late RubyGems
22
+ # activation, and restore_standalone_environment! splices them back.
23
+ rails_family = %w[
24
+ rails railties activesupport activemodel activerecord actionpack
25
+ actionview actionmailer activejob actioncable activestorage
26
+ actionmailbox actiontext
27
+ ].freeze
28
+ Gem.loaded_specs.each_value do |spec|
29
+ next if spec.default_gem? || !rails_family.include?(spec.name)
30
+ spec.full_require_paths.each { |path| $LOAD_PATH.delete(path) }
31
+ end
14
32
  Gem.loaded_specs.delete_if { |_, spec| !spec.default_gem? }
15
33
  end
16
34
 
@@ -74,6 +92,12 @@ class RailsAiContextCLI < Thor
74
92
  no_boot = options[:no_boot]
75
93
  no_boot = true if args.delete("--no-boot")
76
94
 
95
+ # Same for the value-taking class options: after the tool name they
96
+ # arrive as plain args, and dropping them silently sends the command
97
+ # at the wrong app (or wrong RAILS_ENV).
98
+ @app_path_override = extract_value_flag!(args, "--app-path")
99
+ @environment_override = extract_value_flag!(args, "--environment")
100
+
77
101
  boot_rails!(allow_static: true, no_boot: no_boot)
78
102
 
79
103
  runner = ::RailsAiContext::CLI::ToolRunner.new(name, args, json_mode: json_mode)
@@ -173,17 +197,19 @@ class RailsAiContextCLI < Thor
173
197
  def doctor
174
198
  boot_rails!(context: "doctor")
175
199
  result = ::RailsAiContext::Doctor.new.run
200
+ # The report is the command's product - it belongs on stdout so
201
+ # `doctor > report.txt` captures it. stderr stays for errors only.
176
202
  result[:checks].each do |check|
177
203
  icon = case check.status
178
204
  when :pass then "PASS"
179
205
  when :warn then "WARN"
180
206
  when :fail then "FAIL"
181
207
  end
182
- $stderr.puts " [#{icon}] #{check.name}: #{check.message}"
183
- $stderr.puts " Fix: #{check.fix}" if check.fix
208
+ puts " [#{icon}] #{check.name}: #{check.message}"
209
+ puts " Fix: #{check.fix}" if check.fix
184
210
  end
185
- $stderr.puts ""
186
- $stderr.puts "AI Readiness Score: #{result[:score]}/100"
211
+ puts ""
212
+ puts "AI Readiness Score: #{result[:score]}/100"
187
213
 
188
214
  failed = result[:checks].count { |c| c.status == :fail }
189
215
  if options[:strict] && failed > 0
@@ -590,12 +616,29 @@ class RailsAiContextCLI < Thor
590
616
  # capture references BEFORE Rails boot runs and re-register them afterwards.
591
617
  STANDALONE_REQUIRED_GEMS = %w[mcp json-schema addressable public_suffix].freeze
592
618
 
619
+ # Pull a value-taking flag (--flag VALUE or --flag=VALUE) out of the
620
+ # pass-through args that stop_on_unknown_option! leaves after the tool
621
+ # name. Returns the value, or nil when the flag isn't present.
622
+ def extract_value_flag!(args, flag)
623
+ if (idx = args.index(flag))
624
+ args.delete_at(idx)
625
+ return args.delete_at(idx)
626
+ end
627
+ if (pair = args.find { |a| a.start_with?("#{flag}=") })
628
+ args.delete(pair)
629
+ return pair.split("=", 2)[1]
630
+ end
631
+ nil
632
+ end
633
+
593
634
  def boot_rails!(context: nil, allow_static: false, no_boot: false)
594
- app_root = File.expand_path(options[:app_path] || Dir.pwd)
635
+ app_path_opt = @app_path_override || options[:app_path]
636
+ environment_opt = @environment_override || options[:environment]
637
+ app_root = File.expand_path(app_path_opt || Dir.pwd)
595
638
  # Relative paths inside the app (log/, tmp/, sqlite files) assume the
596
639
  # process cwd is the app root, so honor --app-path by moving there.
597
- Dir.chdir(app_root) if options[:app_path]
598
- ENV["RAILS_ENV"] = options[:environment] if options[:environment]
640
+ Dir.chdir(app_root) if app_path_opt
641
+ ENV["RAILS_ENV"] = environment_opt if environment_opt
599
642
 
600
643
  if allow_static && (no_boot || options[:no_boot])
601
644
  enter_static_tier!("static mode requested with --no-boot")
@@ -284,7 +284,7 @@ module RailsAiContext
284
284
  "Extensibility" => <<~SECTION,
285
285
  # ── Extensibility ─────────────────────────────────────────────────
286
286
  # Register additional MCP tool classes alongside the #{RailsAiContext::Server.builtin_tools.size} built-in tools
287
- # config.custom_tools = [MyApp::CustomTool]
287
+ # config.custom_tools = ["MyApp::CustomTool"] # class name as string - resolved after boot
288
288
 
289
289
  # Exclude specific built-in tools by name
290
290
  # config.skip_tools = %w[rails_security_scan]
@@ -13,10 +13,19 @@ module RailsAiContext
13
13
  return true if File.exist?(File.join(root, "config", "mongoid.yml"))
14
14
 
15
15
  lock = File.join(root, "Gemfile.lock")
16
- return false unless File.exist?(lock)
16
+ if File.exist?(lock)
17
+ content = RailsAiContext::SafeFile.read(lock)
18
+ return !!content&.include?(" mongoid (")
19
+ end
17
20
 
18
- content = RailsAiContext::SafeFile.read(lock)
19
- !!content&.include?(" mongoid (")
21
+ # No lockfile yet (fresh checkout, bare directory): fall back to the
22
+ # Gemfile's own declaration so the app still gets Mongoid treatment
23
+ # instead of misleading ActiveRecord answers.
24
+ gemfile = File.join(root, "Gemfile")
25
+ return false unless File.exist?(gemfile)
26
+
27
+ content = RailsAiContext::SafeFile.read(gemfile)
28
+ !!content&.match?(/^\s*gem\s+["']mongoid["']/)
20
29
  end
21
30
  end
22
31
  end
@@ -70,7 +70,7 @@ module RailsAiContext
70
70
  def self.available_tools
71
71
  skip = RailsAiContext.configuration.skip_tools
72
72
  tools = Server.builtin_tools
73
- tools += RailsAiContext.configuration.custom_tools
73
+ tools += Server.resolve_custom_tools
74
74
  return tools if skip.empty?
75
75
  tools.reject { |t| skip.include?(t.tool_name) }
76
76
  end
@@ -27,6 +27,7 @@ module RailsAiContext
27
27
  check_prism
28
28
  check_brakeman
29
29
  check_live_reload
30
+ check_stdio_activation_hygiene
30
31
  check_security_gitignore
31
32
  check_security_auto_mount
32
33
  check_performance_schema_size
@@ -152,7 +153,7 @@ module RailsAiContext
152
153
  migrate_dir = File.join(app.root, "db/migrate")
153
154
  if Dir.exist?(migrate_dir) && Dir.glob(File.join(migrate_dir, "*.rb")).any?
154
155
  count = Dir.glob(File.join(migrate_dir, "*.rb")).size
155
- Check.new(name: "Migrations", status: :pass, message: "#{count} migration files", fix: nil)
156
+ Check.new(name: "Migrations", status: :pass, message: "#{count} migration #{count == 1 ? 'file' : 'files'}", fix: nil)
156
157
  else
157
158
  Check.new(name: "Migrations", status: :warn, message: "No migrations", fix: nil)
158
159
  end
@@ -343,6 +344,46 @@ module RailsAiContext
343
344
  fix: "Check mcp gem: `bundle info mcp`")
344
345
  end
345
346
 
347
+ # RubyGems prints "Resolving dependencies..." to STDOUT when activating
348
+ # this gem needs the full resolver (split/incomplete GEM_PATH, conflicting
349
+ # candidate versions). That happens before any gem code runs and corrupts
350
+ # the MCP stdio stream's pure-JSON framing. Re-run the activation the way
351
+ # the standalone binstub does and flag anything that lands on stdout.
352
+ # In-Gemfile installs activate through the lockfile and are immune.
353
+ def check_stdio_activation_hygiene
354
+ return Check.new(name: "MCP stdio hygiene", status: :pass,
355
+ message: "in-Gemfile install activates via bundler (no resolver output)", fix: nil) unless InstallMode.standalone?
356
+
357
+ require "open3"
358
+ # An MCP client launches the binstub from a clean shell; simulate that
359
+ # exactly. The booted app's Bundler has mutated this process's env
360
+ # (RUBYOPT gets -rbundler/setup; with a configured bundle path even
361
+ # GEM_HOME/GEM_PATH point into vendor/bundle), so start from Bundler's
362
+ # snapshot of the pre-boot environment when it is available.
363
+ base_env = defined?(Bundler) && Bundler.respond_to?(:original_env) ? Bundler.original_env : ENV.to_h
364
+ %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_SETUP BUNDLE_PATH].each { |k| base_env.delete(k) }
365
+ stdout, _stderr, status = Open3.capture3(
366
+ base_env, Gem.ruby, "--disable-gems", "-e",
367
+ "require 'rubygems'; gem 'rails-ai-context'",
368
+ unsetenv_others: true
369
+ )
370
+ if status.success? && stdout.empty?
371
+ Check.new(name: "MCP stdio hygiene", status: :pass,
372
+ message: "gem activation is silent on stdout", fix: nil)
373
+ elsif stdout.empty?
374
+ Check.new(name: "MCP stdio hygiene", status: :warn,
375
+ message: "gem activation exited #{status.exitstatus} (stdout clean)",
376
+ fix: "Run `gem list rails-ai-context` and reinstall if missing")
377
+ else
378
+ Check.new(name: "MCP stdio hygiene", status: :fail,
379
+ message: "gem activation writes to stdout (#{stdout.lines.first.to_s.strip.truncate(60)}) - this corrupts the MCP stdio stream",
380
+ fix: "GEM_PATH is split or incomplete; use the full `gem env path` (see TROUBLESHOOTING: MCP client reports a JSON parse error)")
381
+ end
382
+ rescue => e
383
+ Check.new(name: "MCP stdio hygiene", status: :warn,
384
+ message: "could not verify activation hygiene: #{e.message.truncate(60)}", fix: nil)
385
+ end
386
+
346
387
  # ── Introspector health ───────────────────────────────────────────
347
388
 
348
389
  def check_introspector_health
@@ -467,8 +508,8 @@ module RailsAiContext
467
508
 
468
509
  gitignore = File.read(gitignore_path)
469
510
  issues = []
470
- issues << ".env exists but not in .gitignore" if sensitive_files.include?(".env") && !gitignore.include?(".env")
471
- issues << "config/master.key not in .gitignore" if sensitive_files.include?("config/master.key") && !gitignore.include?("master.key")
511
+ issues << ".env exists but not in .gitignore" if sensitive_files.include?(".env") && !gitignore_covers?(gitignore, ".env")
512
+ issues << "config/master.key not in .gitignore" if sensitive_files.include?("config/master.key") && !gitignore_covers?(gitignore, "config/master.key")
472
513
 
473
514
  if issues.empty?
474
515
  Check.new(name: "Secrets in .gitignore", status: :pass, message: "Sensitive files properly gitignored", fix: nil)
@@ -479,6 +520,83 @@ module RailsAiContext
479
520
  end
480
521
  end
481
522
 
523
+ # Substring checks miss glob entries (Rails 8.1 generates `/config/*.key`
524
+ # rather than a literal master.key line), so match .gitignore patterns
525
+ # the way git does: anchored when the pattern contains a slash (* stops
526
+ # at separators), basename-at-any-depth when it doesn't, last-match-wins
527
+ # ordering, and git's negation rules - a `pattern/` entry only concerns
528
+ # directories, a negation must match the file itself (not a container),
529
+ # and a file cannot be re-included while a parent directory is excluded.
530
+ def gitignore_covers?(content, path)
531
+ gitignore_path_ignored?(parse_gitignore_rules(content), path, dir: false)
532
+ end
533
+
534
+ def parse_gitignore_rules(content)
535
+ content.each_line.filter_map do |line|
536
+ pattern = line.strip
537
+ next if pattern.empty? || pattern.start_with?("#")
538
+
539
+ negation = pattern.start_with?("!")
540
+ pattern = pattern.delete_prefix("!")
541
+ dir_only = pattern.end_with?("/")
542
+ anchored = pattern.chomp("/").include?("/")
543
+ pattern = pattern.delete_prefix("/").chomp("/")
544
+ next if pattern.empty?
545
+
546
+ { negation: negation, dir_only: dir_only, anchored: anchored, pattern: pattern }
547
+ end
548
+ end
549
+
550
+ # Mirrors git's evaluation order: if any ancestor directory ends up
551
+ # ignored (itself evaluated with the same rules, negations included),
552
+ # everything beneath it is ignored and cannot be re-included. Otherwise
553
+ # the path is judged by the last rule matching the path ITSELF - there
554
+ # is no "descend" globbing, which is what made `config/` + `!config/`
555
+ # (a re-included directory) falsely read as still covering its files.
556
+ def gitignore_path_ignored?(rules, path, dir:)
557
+ return true if gitignore_parent_ignored?(rules, path)
558
+
559
+ ignored = false
560
+ rules.each do |rule|
561
+ # `pattern/` entries concern directories only, in both polarities.
562
+ next if rule[:dir_only] && !dir
563
+ next if rule[:negation] && !ignored
564
+
565
+ ignored = !rule[:negation] if gitignore_pattern_matches?(rule, path)
566
+ end
567
+ ignored
568
+ end
569
+
570
+ def gitignore_parent_ignored?(rules, path)
571
+ parts = path.split("/")[0..-2]
572
+ parents = parts.each_index.map { |i| parts[0..i].join("/") }
573
+ parents.any? { |parent| gitignore_path_ignored?(rules, parent, dir: true) }
574
+ end
575
+
576
+ def gitignore_pattern_matches?(rule, path)
577
+ flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
578
+ flags |= File::FNM_CASEFOLD if gitignore_case_insensitive?
579
+ if rule[:anchored]
580
+ File.fnmatch?(rule[:pattern], path, flags) || path == rule[:pattern]
581
+ else
582
+ File.fnmatch?(rule[:pattern], File.basename(path), flags)
583
+ end
584
+ end
585
+
586
+ # git sets core.ignorecase from the filesystem; mirror it by probing
587
+ # whether the .gitignore file itself resolves case-insensitively
588
+ # (APFS/NTFS default). The .gitignore basename always carries cased
589
+ # letters, unlike the app root (which can be all digits).
590
+ def gitignore_case_insensitive?
591
+ return @gitignore_case_insensitive if defined?(@gitignore_case_insensitive)
592
+
593
+ gitignore = File.join(app.root.to_s, ".gitignore")
594
+ swapped = File.join(app.root.to_s, ".GITIGNORE")
595
+ @gitignore_case_insensitive = File.exist?(gitignore) && File.identical?(gitignore, swapped)
596
+ rescue StandardError
597
+ @gitignore_case_insensitive = false
598
+ end
599
+
482
600
  def check_security_auto_mount
483
601
  config = RailsAiContext.configuration
484
602
  if config.auto_mount && defined?(Rails.env) && Rails.env.production?
@@ -29,7 +29,10 @@ module RailsAiContext
29
29
  config.introspectors.each do |name|
30
30
  introspector = resolve_introspector(name)
31
31
  context[name] = run_introspector(introspector)
32
- rescue => e
32
+ rescue StandardError, ScriptError => e
33
+ # ScriptError included: a syntax-broken app file surfacing through
34
+ # eager loading must cost one section, not the whole process (the
35
+ # MCP server would otherwise die mid-session).
33
36
  context[name] = { error: e.message }
34
37
  RailsAiContext.log_warn "[rails-ai-context] #{name} introspection failed: #{e.message}"
35
38
  end