rails-ai-context 5.16.0 → 5.16.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59c335a48ab2976318ed2fc58d1cce4e2153c0db0144b61ca8d902f160bbeb76
4
- data.tar.gz: 334cc340093515797006c8326b47fa97721c9e66f07f940f3ec0864db2ae241a
3
+ metadata.gz: eea6e2b178ca5641f69ba9e78d20faa743e9639c706b2a8f847e60f854618b97
4
+ data.tar.gz: d688b1ac2b70859c0f2cdfecfb731bf0dda802743924664967f15680b4e9042f
5
5
  SHA512:
6
- metadata.gz: f0e5532e52f873a3cb659dbf90fde582974f4840609f0c8365318f5b862659475484232e9ead3fd0074ec1a079db565cd8046a157ceb4f1bc1099b08877feded
7
- data.tar.gz: e6bd4fe02b30344a494a8af944ab6ca94e07f944d143d99bd4f116ca3f15099a5fbbe840b38c1b186398c43c3b23825889fd4b852593fc09342edc50910cd4e9
6
+ metadata.gz: bb7f069772ba9a58014b905eb68629cf8024b767c7cd01f409fbcd4418f252e04b7d0618c39160bcc7f2364e4535240372da158d267284f721fb365409216699
7
+ data.tar.gz: b22aac0130e6ee73829982f5dce526036c5e944ed2104f93664ec89868df7a1db26ea12412f5d3c6d73c7daa9a4421e2e61bf7054f67814fde12b9da70c3a206
data/CHANGELOG.md CHANGED
@@ -5,6 +5,87 @@ 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.2] - 2026-07-17
9
+
10
+ ### Fixed
11
+
12
+ - **Standalone static tier works when installed via `gem install`.**
13
+ `tool <name> --no-boot`, `tool <name> --help`, and `tool --list` outside
14
+ an app all died with `cannot load such file -- active_support/...`. The
15
+ RubyGems binstub eagerly activates the whole dependency tree, so the
16
+ exe's framework-path strip left every rails-family spec still flagged as
17
+ activated and `Gem.try_activate` refused to re-add the stripped paths.
18
+ The exe now stashes the stripped specs and splices their load paths back
19
+ at every app-less require site (Bundler-pinned paths still win after a
20
+ boot). Verified through the real binstub in an empty directory, in an
21
+ app whose Gemfile does not include the gem, and on the boot-failed
22
+ static fallback.
23
+ - **Engine-mounted MCP survives mcp SDK 0.24.** The SDK's SSE writer now
24
+ calls `stream.flush` after every event, and
25
+ `ActionController::Live::Buffer` defines no `flush` - every SSE-mode
26
+ `tools/call` through `mount RailsAiContext::Engine` 500'd on all
27
+ supported Rails versions (the payload still arrived, then the request
28
+ died). The controller now hands the transport a flush-capable stream.
29
+ Verified with live curl sessions on booted Rails 7.1 and 8.1 apps.
30
+ - **Engine-mounted GET streams actually stay open.** The transport
31
+ registers the server-push stream and returns; Live then closed the
32
+ response as soon as the action returned, so the SSE channel died
33
+ instantly, `notifications/tools/list_changed` could never be delivered,
34
+ and (once held open) clients would have waited up to 30s for response
35
+ headers. The action now commits headers immediately with an SSE comment
36
+ and holds the thread until the transport or the client closes the
37
+ stream. SETUP.md documents the one-thread-per-connected-client cost.
38
+ - **Helpers keep their namespaces.** `app/helpers/admin/dashboard_helper.rb`
39
+ was reported as `DashboardHelper`, and looking it up by its real constant
40
+ name `Admin::DashboardHelper` failed. Module names now derive from the
41
+ path under `app/helpers` (`app/helpers/concerns` stays its own root,
42
+ matching the railties autoload glob), and exact path matches win before
43
+ basename fallbacks so a top-level helper is not shadowed by a namespaced
44
+ one.
45
+ - **`rails_validate` checks qualified render targets in Ruby files.**
46
+ `render partial: "posts/missing"` in a controller or service passed
47
+ `level:"rails"` silently (the partial check only ran for ERB). Bare
48
+ `render "posts/show"` keeps template semantics in Ruby files, so the
49
+ controller template-render idiom is not false-flagged. Tool descriptions
50
+ now state exactly which column references are checked
51
+ (validates/permit/callbacks).
52
+ - **`rails_review_changes` describes untracked files.** New files rendered
53
+ as empty headings (a fresh app right after `rails new` plus install
54
+ showed 20 of them); they now read `_new file, N lines_`, and only the
55
+ HEAD flow makes that claim (a committed-then-reverted file against an
56
+ older ref reads `_no diff available_`).
57
+ - **Views resource resolves extension-less paths.** Reading
58
+ `rails-ai-context://views/posts/index` now serves
59
+ `posts/index.html.erb`; a same-named directory no longer defeats the
60
+ lookup, and sensitive-file candidates are rejected so the
61
+ not-found/not-allowed message split cannot act as an existence oracle
62
+ for secrets placed under `app/views`.
63
+ - **Resource templates accept both URI schemes.** `rails://` and
64
+ `rails-ai-context://` now both resolve for the controllers, views, and
65
+ routes templates, and contents echo the URI the client requested.
66
+ - **`preset` output no longer scrambles under pipes** in either CLI:
67
+ all framing (banner, separators, "Running:" labels) goes to stderr and
68
+ stdout carries pure tool output.
69
+ - **Context files stop leaking internal tokens.** Architecture sections
70
+ showed raw keys (`concerns_models`, `pwa`, `solid_queue`) next to
71
+ humanized labels; every token the convention introspector emits now has
72
+ a label. Stale doc counts corrected across README and docs (39 tools,
73
+ 25 doctor checks).
74
+
75
+ ## [5.16.1] - 2026-07-12
76
+
77
+ ### Fixed
78
+
79
+ - **Engine-mounted MCP works on Rails 7.0-7.2** (caught by the release e2e
80
+ matrix minutes after 5.16.0 shipped; 8.x was unaffected). The MCP
81
+ transport answers SSE-mode requests with a Rack 3 streaming body (a
82
+ Proc); Rails 7.x's response buffer fed that Proc through Rack::ETag,
83
+ which 500'd every `tools/call` through
84
+ `mount RailsAiContext::Engine, at: "/mcp"`. `McpController` now includes
85
+ `ActionController::Live` and pumps callable bodies through the live
86
+ stream (enumerable bodies are joined to a plain string), verified with
87
+ real curl MCP sessions against booted Rails 7.0, 7.1, 7.2, and 8.0 apps.
88
+
8
89
  ## [5.16.0] - 2026-07-12
9
90
 
10
91
  ### Fixed
data/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
  <br>
22
22
  [![Ruby](https://img.shields.io/badge/Ruby-3.1%20%7C%203.2%20%7C%203.3%20%7C%203.4-CC342D)](https://github.com/crisnahine/rails-ai-context)
23
23
  [![Rails](https://img.shields.io/badge/Rails-7.0%20%7C%207.1%20%7C%207.2%20%7C%208.0%20%7C%208.1-CC0000)](https://github.com/crisnahine/rails-ai-context)
24
- [![Tests](https://img.shields.io/badge/Tests-2501%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
24
+ [![Tests](https://img.shields.io/badge/Tests-2608%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
25
25
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
26
26
 
27
27
  </div>
@@ -245,7 +245,7 @@ rails 'ai:tool[stimulus]' controller=chart
245
245
 
246
246
  <br>
247
247
 
248
- ## 38 Tools
248
+ ## 39 Tools
249
249
 
250
250
  Every tool is **read-only** and returns data verified against your actual app - not guesses, not training data.
251
251
 
@@ -509,7 +509,7 @@ empty listings.
509
509
 
510
510
  ## Build your own tools
511
511
 
512
- Register custom MCP tools alongside the 38 built-in ones:
512
+ Register custom MCP tools alongside the 39 built-in ones:
513
513
 
514
514
  ```ruby
515
515
  # app/mcp_tools/rails_get_business_metrics.rb
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "delegate"
4
+
3
5
  module RailsAiContext
4
6
  # Rails controller for serving MCP over Streamable HTTP.
5
7
  # Alternative to the Rack middleware - integrates with Rails routing,
@@ -7,11 +9,73 @@ module RailsAiContext
7
9
  #
8
10
  # Mount in routes: mount RailsAiContext::Engine, at: "/mcp"
9
11
  class McpController < ActionController::API
12
+ # Live: the transport answers SSE-mode requests with a Rack 3 streaming
13
+ # body (a Proc that writes to a stream). Handing that Proc to a plain
14
+ # controller works only where the response body passes through untouched;
15
+ # Rails 7.x's response buffer feeds it through Rack::ETag, which 500s on
16
+ # the callable. Live's stream is exactly the interface the Proc expects
17
+ # (write/close) and keeps every middleware away from the body on all
18
+ # supported Rails versions.
19
+ include ActionController::Live
20
+
21
+ # The MCP SDK's SSE writer calls stream.flush after every event, but
22
+ # Live's buffer writes straight through to the client and defines no
23
+ # flush - the NoMethodError would 500 the request after the payload was
24
+ # already delivered and tear down long-lived GET streams.
25
+ class FlushableStream < SimpleDelegator
26
+ def flush; end
27
+ end
28
+
10
29
  def handle
11
- rack_response = self.class.mcp_transport.handle_request(request)
12
- self.status = rack_response[0]
13
- rack_response[1].each { |k, v| response.headers[k] = v }
14
- self.response_body = rack_response[2]
30
+ status_code, rack_headers, body = self.class.mcp_transport.handle_request(request)
31
+ self.status = status_code
32
+ rack_headers.each { |k, v| response.headers[k] = v }
33
+
34
+ if body.respond_to?(:each)
35
+ # Plain enumerable body (initialize, errors, JSON mode): join to a
36
+ # string so Content-Length/ETag semantics stay conventional.
37
+ chunks = []
38
+ body.each { |chunk| chunks << chunk }
39
+ body.close if body.respond_to?(:close)
40
+ self.response_body = chunks.join
41
+ elsif body.respond_to?(:call)
42
+ stream = response.stream
43
+ stream = FlushableStream.new(stream) unless stream.respond_to?(:flush)
44
+ begin
45
+ body.call(stream)
46
+ # A GET opens the server-push channel: the transport registers the
47
+ # stream and returns, expecting it to outlive this call. Live closes
48
+ # the response when the action returns, so hold the thread until the
49
+ # transport's keepalive (or the client) closes the stream. Live also
50
+ # sends headers only on the first write - the transport writes
51
+ # nothing until its first keepalive ping, so commit with an SSE
52
+ # comment up front or clients sit waiting on headers.
53
+ if request.get?
54
+ begin
55
+ stream.write(": connected\n\n")
56
+ rescue IOError
57
+ nil
58
+ end
59
+ wait_for_stream_close
60
+ end
61
+ ensure
62
+ begin
63
+ response.stream.close
64
+ rescue StandardError
65
+ nil
66
+ end
67
+ end
68
+ else
69
+ self.response_body = body
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def wait_for_stream_close
76
+ sleep 0.5 until response.stream.closed?
77
+ rescue IOError
78
+ nil
15
79
  end
16
80
 
17
81
  class << self
data/docs/ARCHITECTURE.md CHANGED
@@ -30,7 +30,7 @@ graph TD
30
30
  H["Hydration Layer\nSchema hints\ninjected into\ntool responses"]
31
31
  end
32
32
 
33
- engine --> R["Tool Registry\n38 tools auto-discovered via inherited\n+ custom_tools - skip_tools = active tools"]
33
+ engine --> R["Tool Registry\n39 tools auto-discovered via inherited\n+ custom_tools - skip_tools = active tools"]
34
34
  end
35
35
 
36
36
  R --> MCP
@@ -40,7 +40,7 @@ graph TD
40
40
  subgraph outputs["Output"]
41
41
  direction LR
42
42
  MCP["MCP Server\nstdio / HTTP\nResources\nVFS URIs"]
43
- CLI["CLI Runner\nRake / Thor\nSame 38 tools\nNo server needed"]
43
+ CLI["CLI Runner\nRake / Thor\nSame 39 tools\nNo server needed"]
44
44
  S["Serializers\n14 modules\nStatic files\nPer-AI-tool"]
45
45
  end
46
46
 
@@ -192,7 +192,7 @@ Thor-based CLI that works standalone (no Gemfile entry):
192
192
 
193
193
  - `ToolRunner` - Parses CLI args, resolves tool names, executes tools, formats output
194
194
  - Supports `--json` mode for machine-readable output
195
- - Same 38 tools available as MCP and CLI
195
+ - Same 39 tools available as MCP and CLI
196
196
 
197
197
  ### Caching
198
198
 
@@ -215,7 +215,7 @@ SHA256-based change detection:
215
215
  ## Key design decisions
216
216
 
217
217
  1. **Official MCP SDK** - Not a custom protocol. Uses `mcp` gem's `MCP::Tool`, `MCP::Server`, transports.
218
- 2. **Read-only tools** - All 38 tools annotated as non-destructive. Defense-in-depth for query tool.
218
+ 2. **Read-only tools** - All 39 tools annotated as non-destructive. Defense-in-depth for query tool.
219
219
  3. **Graceful degradation** - Works without database (parses schema.rb as text), without Brakeman, without ripgrep, without listen gem.
220
220
  4. **Zeitwerk autoloading** - Files loaded on-demand. No `require_relative` in the gem.
221
221
  5. **Diff-aware generation** - Context file regeneration skips unchanged files using fingerprinting.
data/docs/CLI.md CHANGED
@@ -40,7 +40,7 @@ rails-ai-context serve --transport http --port 6029 # HTTP transport
40
40
 
41
41
  ### `tool`
42
42
 
43
- Run any of the 38 MCP tools from the terminal.
43
+ Run any of the 39 MCP tools from the terminal.
44
44
 
45
45
  ```bash
46
46
  # Rake syntax
@@ -93,7 +93,7 @@ rails-ai-context context --format claude # Specific format
93
93
 
94
94
  ### `doctor`
95
95
 
96
- Run 23 diagnostic checks and report AI readiness score.
96
+ Run the diagnostic checks and report an AI readiness score.
97
97
 
98
98
  ```bash
99
99
  rails ai:doctor
data/docs/CUSTOM_TOOLS.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Custom Tools
4
4
 
5
- **Build your own MCP tools that run alongside the 38 built-in ones.**
5
+ **Build your own MCP tools that run alongside the 39 built-in ones.**
6
6
 
7
7
  [Tools Reference](TOOLS.md) · [Configuration](CONFIGURATION.md) · [Architecture](ARCHITECTURE.md) · [FAQ](FAQ.md)
8
8
 
@@ -11,7 +11,7 @@
11
11
  ---
12
12
 
13
13
  > [!NOTE]
14
- > Custom tools have full access to your Rails environment - ActiveRecord, services, mailers, everything. They appear alongside the 38 built-in tools in both MCP and CLI.
14
+ > Custom tools have full access to your Rails environment - ActiveRecord, services, mailers, everything. They appear alongside the 39 built-in tools in both MCP and CLI.
15
15
 
16
16
  ## Creating a custom tool
17
17
 
data/docs/FAQ.md CHANGED
@@ -25,7 +25,7 @@ Claude Code, Cursor, GitHub Copilot, OpenCode, and Codex CLI. Each gets tailored
25
25
  No. The gem works three ways:
26
26
  1. **MCP server** - AI calls tools via the protocol (best experience)
27
27
  2. **Static files** - Generated context files (CLAUDE.md, .cursor/rules/, etc.)
28
- 3. **CLI** - Same 38 tools from the terminal, no server needed
28
+ 3. **CLI** - Same 39 tools from the terminal, no server needed
29
29
 
30
30
  ### Is this safe for production?
31
31
 
@@ -47,7 +47,7 @@ Yes. The gem gracefully degrades - it parses `db/schema.rb` as text when no data
47
47
 
48
48
  ### Can I switch between Gemfile and standalone?
49
49
 
50
- Yes, freely. Both generate identical context files and provide the same 38 tools. Just re-run the install/init to update MCP config files.
50
+ Yes, freely. Both generate identical context files and provide the same 39 tools. Just re-run the install/init to update MCP config files.
51
51
 
52
52
  ### Do I need to commit the generated files?
53
53
 
@@ -69,7 +69,7 @@ Start with `rails_onboard` for an app overview, `rails_analyze_feature` for feat
69
69
 
70
70
  ### Can I add my own tools?
71
71
 
72
- Yes. See [Custom Tools](CUSTOM_TOOLS.md). Create an `MCP::Tool` subclass, register it via `config.custom_tools`, and it appears alongside the 38 built-in tools.
72
+ Yes. See [Custom Tools](CUSTOM_TOOLS.md). Create an `MCP::Tool` subclass, register it via `config.custom_tools`, and it appears alongside the 39 built-in tools.
73
73
 
74
74
  ### Can I remove built-in tools?
75
75
 
data/docs/GUIDE.md CHANGED
@@ -18,7 +18,7 @@
18
18
  | Guide | Description |
19
19
  |:------|:------------|
20
20
  | [Quickstart](QUICKSTART.md) | Get running in 5 minutes |
21
- | [Tools Reference](TOOLS.md) | All 38 MCP tools with parameters |
21
+ | [Tools Reference](TOOLS.md) | All 39 MCP tools with parameters |
22
22
  | [Recipes](RECIPES.md) | Real-world workflows and examples |
23
23
  | [Custom Tools](CUSTOM_TOOLS.md) | Build your own MCP tools |
24
24
  | [Configuration](CONFIGURATION.md) | Every config option |
@@ -283,7 +283,7 @@ rails ai:context:claude # Use this instead (no quoting needed)
283
283
 
284
284
  ## CLI Tools
285
285
 
286
- All 38 MCP tools can be run directly from the terminal - no MCP server or AI client needed.
286
+ All 39 MCP tools can be run directly from the terminal - no MCP server or AI client needed.
287
287
 
288
288
  ### Rake
289
289
 
@@ -349,7 +349,7 @@ The `tool_mode` is selected during `rails generate rails_ai_context:install`.
349
349
 
350
350
  ## MCP Tools - Full Reference
351
351
 
352
- All 38 tools are **read-only** and **idempotent** - they never modify your application or database.
352
+ All 39 tools are **read-only** and **idempotent** - they never modify your application or database.
353
353
 
354
354
  ### rails_get_schema
355
355
 
@@ -1169,7 +1169,7 @@ if defined?(RailsAiContext)
1169
1169
  end
1170
1170
  ```
1171
1171
 
1172
- Both transports are **read-only** - they expose the same 38 tools and never modify your app.
1172
+ Both transports are **read-only** - they expose the same 39 tools and never modify your app.
1173
1173
 
1174
1174
  ### Controller Transport (Alternative)
1175
1175
 
data/docs/QUICKSTART.md CHANGED
@@ -62,7 +62,7 @@ The install generator created:
62
62
  ## Verify it works
63
63
 
64
64
  ```bash
65
- # Check AI readiness (23 diagnostic checks)
65
+ # Check AI readiness (25 diagnostic checks)
66
66
  rails ai:doctor # In-Gemfile
67
67
  rails-ai-context doctor # Standalone
68
68
 
data/docs/SECURITY.md CHANGED
@@ -11,11 +11,11 @@
11
11
  ---
12
12
 
13
13
  > [!CAUTION]
14
- > This gem is designed for **development environments**. The query tool is disabled in production by default. Sensitive files are blocked. All 38 tools are read-only.
14
+ > This gem is designed for **development environments**. The query tool is disabled in production by default. Sensitive files are blocked. All 39 tools are read-only.
15
15
 
16
16
  ## Design principles
17
17
 
18
- 1. **Read-only by design** - All 38 tools are annotated as non-destructive in the MCP protocol
18
+ 1. **Read-only by design** - All 39 tools are annotated as non-destructive in the MCP protocol
19
19
  2. **Defense in depth** - Multiple security layers, not single points of failure
20
20
  3. **Sensitive data blocking** - Configurable patterns prevent access to secrets
21
21
  4. **Offline by default** - No network calls except optional `rails_search_docs` with `fetch: true`
data/docs/SETUP.md CHANGED
@@ -134,7 +134,7 @@ The MCP tools rule uses `alwaysApply: false` with a descriptive `description:` f
134
134
 
135
135
  ```markdown
136
136
  ---
137
- description: 38 MCP tools for Rails introspection - schema, models, routes, controllers, views
137
+ description: 39 MCP tools for Rails introspection - schema, models, routes, controllers, views
138
138
  alwaysApply: false
139
139
  ---
140
140
  ```
@@ -268,6 +268,8 @@ Instead of stdio, you can mount the MCP server inside your Rails app:
268
268
  mount RailsAiContext::Engine, at: "/mcp"
269
269
  ```
270
270
 
271
+ Each connected MCP client that opens the server-push channel (a long-lived SSE `GET /mcp`) holds one server thread for the life of the connection. With Puma's default small thread pool, a handful of connected clients can exhaust it - fine for development, but raise the thread count (or prefer the standalone `rails-ai-context serve --transport http` process) if several clients or other traffic share the app.
272
+
271
273
  Then point your AI tool's MCP config to the HTTP endpoint instead of a command:
272
274
 
273
275
  ```json
data/docs/STANDALONE.md CHANGED
@@ -51,7 +51,7 @@ rails-ai-context version # Show version
51
51
  2. **Restores `$LOAD_PATH`** entries that `Bundler.setup` strips (since the gem isn't in the Gemfile)
52
52
  3. **YAML config** - uses `.rails-ai-context.yml` instead of a Ruby initializer
53
53
 
54
- This means you get the same 38 tools, same MCP server, same context generation - without touching the project's Gemfile.
54
+ This means you get the same 39 tools, same MCP server, same context generation - without touching the project's Gemfile.
55
55
 
56
56
  ## Configuration via YAML
57
57
 
@@ -140,7 +140,7 @@ bundle remove rails-ai-context
140
140
  rails-ai-context init
141
141
  ```
142
142
 
143
- The MCP config files are updated automatically. Both modes generate identical context files and provide the same 38 tools.
143
+ The MCP config files are updated automatically. Both modes generate identical context files and provide the same 39 tools.
144
144
 
145
145
  ## Troubleshooting
146
146
 
data/docs/TOOLS.md CHANGED
@@ -276,8 +276,8 @@ Syntax + semantic + Brakeman security validation in one call.
276
276
 
277
277
  | Parameter | Type | Default | Description |
278
278
  |:----------|:-----|:--------|:------------|
279
- | `files` | string | - | Comma-separated file paths |
280
- | `level` | enum | `syntax` | `syntax`, `rails`, `security` |
279
+ | `files` | array | required | File paths to validate |
280
+ | `level` | enum | `syntax` | `syntax`, `rails` (rails includes the Brakeman security pass) |
281
281
 
282
282
  ### `rails_security_scan`
283
283
 
@@ -11,7 +11,7 @@
11
11
  ---
12
12
 
13
13
  > [!TIP]
14
- > Always start with `rails ai:doctor`. It runs 23 checks and catches most issues automatically.
14
+ > Always start with `rails ai:doctor`. It runs 25 checks and catches most issues automatically.
15
15
 
16
16
  ## Diagnostics first
17
17
 
@@ -20,7 +20,7 @@ rails ai:doctor # In-Gemfile
20
20
  rails-ai-context doctor # Standalone
21
21
  ```
22
22
 
23
- This runs 23 checks and returns an AI readiness score (0-100). Each failed check includes a fix suggestion.
23
+ This runs 25 checks and returns an AI readiness score (0-100). Each failed check includes a fix suggestion.
24
24
 
25
25
  ---
26
26
 
data/docs/index.md CHANGED
@@ -4,7 +4,7 @@ title: Home
4
4
 
5
5
  # rails-ai-context Documentation
6
6
 
7
- **38 MCP tools that give AI agents live access to your Rails app.**
7
+ **39 MCP tools that give AI agents live access to your Rails app.**
8
8
 
9
9
  Works with Claude Code, Cursor, GitHub Copilot, OpenCode, and Codex CLI.
10
10
 
data/exe/rails-ai-context CHANGED
@@ -10,6 +10,7 @@
10
10
  # Skip this when running under `bundle exec` — Bundler has already resolved gems
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
+ stripped_framework_specs = {}
13
14
  if defined?(Gem) && Gem.respond_to?(:loaded_specs) && !ENV["BUNDLE_BIN_PATH"]
14
15
  # Also strip the Rails-family gems' lib paths. Leaving them in $LOAD_PATH
15
16
  # lets the app's boot resolve framework files from the NEWEST installed
@@ -27,11 +28,19 @@ if defined?(Gem) && Gem.respond_to?(:loaded_specs) && !ENV["BUNDLE_BIN_PATH"]
27
28
  ].freeze
28
29
  Gem.loaded_specs.each_value do |spec|
29
30
  next if spec.default_gem? || !rails_family.include?(spec.name)
31
+ stripped_framework_specs[spec.name] = spec
30
32
  spec.full_require_paths.each { |path| $LOAD_PATH.delete(path) }
31
33
  end
32
34
  Gem.loaded_specs.delete_if { |_, spec| !spec.default_gem? }
33
35
  end
34
36
 
37
+ # App-less modes (--no-boot, tool --help, tool --list outside an app) need
38
+ # these paths spliced back by hand: the specs above keep their activated
39
+ # flag after the strip, so Gem.try_activate declines to re-add the paths and
40
+ # any framework require fails. There is no Bundler.setup in those modes to
41
+ # rebuild $LOAD_PATH either.
42
+ STRIPPED_FRAMEWORK_SPECS = stripped_framework_specs.freeze
43
+
35
44
  require "thor"
36
45
  require "yaml"
37
46
 
@@ -73,7 +82,7 @@ class RailsAiContextCLI < Thor
73
82
  end
74
83
 
75
84
  if args.include?("--help") || args.include?("-h")
76
- require "rails_ai_context"
85
+ require_gem_without_app!
77
86
  tool_class = ::RailsAiContext::CLI::ToolRunner.new(name, []).tool_class
78
87
  puts ::RailsAiContext::CLI::ToolRunner.tool_help(tool_class)
79
88
  return
@@ -295,10 +304,13 @@ class RailsAiContextCLI < Thor
295
304
  $stderr.puts "=" * 60
296
305
  $stderr.puts ""
297
306
 
307
+ # All framing goes to stderr so stdout stays pure tool output - mixing
308
+ # the two scrambles ordering under pipes (stderr is unbuffered, piped
309
+ # stdout is block-buffered).
298
310
  preset[:tools].each do |tool_spec|
299
311
  begin
300
312
  $stderr.puts "-" * 40
301
- puts "Running: #{tool_spec[:name]}"
313
+ $stderr.puts "Running: #{tool_spec[:name]}"
302
314
  $stderr.puts "-" * 40
303
315
  runner = ::RailsAiContext::CLI::ToolRunner.new(
304
316
  tool_spec[:name],
@@ -563,7 +575,7 @@ class RailsAiContextCLI < Thor
563
575
  if defined?(::RailsAiContext::Server)
564
576
  ::RailsAiContext::Server.builtin_tools.size.to_s
565
577
  else
566
- "38"
578
+ "39"
567
579
  end
568
580
  end
569
581
 
@@ -586,7 +598,7 @@ class RailsAiContextCLI < Thor
586
598
  end
587
599
 
588
600
  begin
589
- require "rails_ai_context"
601
+ require_gem_without_app!
590
602
  puts ::RailsAiContext::CLI::ToolRunner.tool_list
591
603
  puts ""
592
604
  puts "Run inside a Rails app to execute tools - static source analysis works even when the app can't boot (see --no-boot)."
@@ -750,11 +762,23 @@ class RailsAiContextCLI < Thor
750
762
  $stderr.puts "[rails-ai-context] Try `gem install rails-ai-context` to ensure all transitive deps are installed."
751
763
  end
752
764
 
765
+ # Loads the gem with no booted app. Framework gems whose load paths the
766
+ # top-of-file strip removed are spliced back from the stashed specs -
767
+ # except any gem a completed Bundler.setup already resolved, whose pinned
768
+ # paths must keep winning over the newest installed version.
769
+ def require_gem_without_app!
770
+ STRIPPED_FRAMEWORK_SPECS.each do |name, spec|
771
+ next if Gem.loaded_specs.key?(name)
772
+ spec.full_require_paths.each { |p| $LOAD_PATH.unshift(p) unless $LOAD_PATH.include?(p) }
773
+ end
774
+ require "rails_ai_context"
775
+ end
776
+
753
777
  # Static tier: the gem loads standalone (no app constants), introspection
754
778
  # runs against the filesystem, and every tool response carries the tier
755
779
  # banner so consumers know runtime data is absent.
756
780
  def enter_static_tier!(reason)
757
- require "rails_ai_context"
781
+ require_gem_without_app!
758
782
  ::RailsAiContext.tier = :static
759
783
  ::RailsAiContext.static_reason = reason
760
784
  ::RailsAiContext.configuration.app_root = Dir.pwd
@@ -145,6 +145,17 @@ module RailsAiContext
145
145
  def handle_read(params)
146
146
  uri = params[:uri]
147
147
 
148
+ # The two schemes are historical; accept either one for every
149
+ # template so clients don't have to remember which resource uses
150
+ # which. Bare "rails://controllers" (no path) stays a static
151
+ # resource; models already resolve under both schemes (legacy
152
+ # handler below, VFS for the rails-ai-context:// form). Contents are
153
+ # relabeled with the URI the client actually requested.
154
+ if uri.match?(%r{\Arails://(controllers|views|routes)/.})
155
+ normalized = uri.sub("rails://", "#{VFS::SCHEME}://")
156
+ return VFS.resolve(normalized).map { |content| content.merge(uri: uri) }
157
+ end
158
+
148
159
  # Delegate rails-ai-context:// URIs to the VFS dispatcher
149
160
  return VFS.resolve(uri) if uri.start_with?("#{VFS::SCHEME}://")
150
161
 
@@ -575,16 +575,19 @@ namespace :ai do
575
575
  end
576
576
 
577
577
  preset = presets[name]
578
- puts "=" * 60
579
- puts " Preset: #{name} - #{preset[:desc]}"
580
- puts "=" * 60
581
- puts ""
578
+ # All framing goes to stderr so stdout stays pure tool output - mixing
579
+ # the two scrambles ordering under pipes (stderr is unbuffered, piped
580
+ # stdout is block-buffered).
581
+ $stderr.puts "=" * 60
582
+ $stderr.puts " Preset: #{name} - #{preset[:desc]}"
583
+ $stderr.puts "=" * 60
584
+ $stderr.puts ""
582
585
 
583
586
  preset[:tools].each do |tool_spec|
584
587
  begin
585
- puts "-" * 40
586
- puts "Running: #{tool_spec[:name]}"
587
- puts "-" * 40
588
+ $stderr.puts "-" * 40
589
+ $stderr.puts "Running: #{tool_spec[:name]}"
590
+ $stderr.puts "-" * 40
588
591
  runner = RailsAiContext::CLI::ToolRunner.new(
589
592
  tool_spec[:name],
590
593
  tool_spec[:params]
@@ -111,7 +111,24 @@ module RailsAiContext
111
111
  "importmaps" => "Import maps (no JS bundler)",
112
112
  "docker" => "Dockerized",
113
113
  "kamal" => "Kamal deployment",
114
- "ci_github_actions" => "GitHub Actions CI"
114
+ "ci_github_actions" => "GitHub Actions CI",
115
+ "concerns_models" => "Model concerns (app/models/concerns/)",
116
+ "concerns_controllers" => "Controller concerns (app/controllers/concerns/)",
117
+ "validators" => "Custom validators (app/validators/)",
118
+ "policies" => "Policy objects (app/policies/)",
119
+ "serializers" => "Serializers (app/serializers/)",
120
+ "notifiers" => "Notifiers (app/notifiers/)",
121
+ "phlex" => "Phlex views",
122
+ "pwa" => "PWA (app/views/pwa/)",
123
+ "dry_rb" => "dry-rb stack",
124
+ "multi_tenant" => "Multi-tenancy",
125
+ "feature_flags" => "Feature flags",
126
+ "error_monitoring" => "Error monitoring",
127
+ "event_driven" => "Event-driven messaging",
128
+ "zeitwerk" => "Zeitwerk autoloading",
129
+ "solid_queue" => "Solid Queue (jobs)",
130
+ "solid_cache" => "Solid Cache",
131
+ "solid_cable" => "Solid Cable"
115
132
  }.freeze
116
133
 
117
134
  PATTERN_LABELS = {
@@ -75,16 +75,31 @@ module RailsAiContext
75
75
  list_helpers(helper_files, real_helpers_dir, real_root, max_size, detail, offset: offset, limit: limit)
76
76
  end
77
77
 
78
+ # Module name from the path under app/helpers, so nested helpers keep
79
+ # their namespace (admin/dashboard_helper.rb => Admin::DashboardHelper).
80
+ # app/helpers/concerns is its own autoload root (railties globs
81
+ # "{*,*/concerns}"), so concerns/formattable.rb defines Formattable,
82
+ # not Concerns::Formattable.
83
+ private_class_method def self.module_name_for(file_path, helpers_dir)
84
+ file_path.sub("#{helpers_dir}/", "").delete_prefix("concerns/").delete_suffix(".rb").camelize
85
+ end
86
+
78
87
  private_class_method def self.show_helper(name, helper_files, helpers_dir, root, max_size, detail)
79
- # Find by module name or file name
88
+ # Find by module name (with or without namespace) or file name.
89
+ # Exact relative-path matches win before basename fallbacks so a
90
+ # top-level DashboardHelper isn't shadowed by admin/dashboard_helper.
80
91
  underscore = name.underscore.delete_suffix("_helper")
81
92
  file_path = helper_files.find do |f|
93
+ rel = f.sub("#{helpers_dir}/", "").delete_suffix(".rb")
94
+ rel == name.underscore || rel == "#{underscore}_helper"
95
+ end
96
+ file_path ||= helper_files.find do |f|
82
97
  basename = File.basename(f, ".rb")
83
98
  basename == "#{underscore}_helper" || basename == underscore || basename == name.underscore
84
99
  end
85
100
 
86
101
  unless file_path
87
- available = helper_files.map { |f| File.basename(f, ".rb").camelize }
102
+ available = helper_files.map { |f| module_name_for(f, helpers_dir) }
88
103
  return not_found_response("Helper", name, available,
89
104
  recovery_tool: "Call rails_get_helper_methods() to see all helpers")
90
105
  end
@@ -96,7 +111,7 @@ module RailsAiContext
96
111
  source = RailsAiContext::SafeFile.read(file_path)
97
112
  return text_response("Could not read helper file: #{file_path}") unless source
98
113
  relative_path = file_path.sub("#{root}/", "")
99
- module_name = File.basename(file_path, ".rb").camelize
114
+ module_name = module_name_for(file_path, helpers_dir)
100
115
 
101
116
  lines = [ "# #{module_name}", "" ]
102
117
  lines << "**File:** `#{relative_path}` (#{source.lines.size} lines)"
@@ -149,7 +164,7 @@ module RailsAiContext
149
164
  private_class_method def self.list_helpers(helper_files, helpers_dir, root, max_size, detail, offset: 0, limit: nil)
150
165
  helpers_data = helper_files.filter_map do |file_path|
151
166
  relative = file_path.sub("#{root}/", "")
152
- module_name = File.basename(file_path, ".rb").camelize
167
+ module_name = module_name_for(file_path, helpers_dir)
153
168
 
154
169
  if File.size(file_path) <= max_size
155
170
  source = RailsAiContext::SafeFile.read(file_path)
@@ -178,6 +178,22 @@ module RailsAiContext
178
178
  lines.concat(content_lines.map(&:rstrip))
179
179
  lines << "```"
180
180
  end
181
+ else
182
+ # In the HEAD flow a nil diff means the file is untracked -
183
+ # summarize it so the section isn't an empty heading. Against an
184
+ # arbitrary ref a nil diff can also mean a committed change that
185
+ # was later reverted in the working tree, so don't claim "new".
186
+ full_path = File.join(root, file)
187
+ if ref == "HEAD" && File.file?(full_path)
188
+ line_count = begin
189
+ File.foreach(full_path).count
190
+ rescue StandardError
191
+ nil
192
+ end
193
+ lines << (line_count ? "_new file, #{line_count} lines_" : "_new file_")
194
+ else
195
+ lines << "_no diff available_"
196
+ end
181
197
  end
182
198
 
183
199
  # Pull relevant context per file type
@@ -11,7 +11,8 @@ module RailsAiContext
11
11
  tool_name "rails_validate"
12
12
  description "Validate syntax and semantics of Ruby, ERB, and JavaScript files in a single call. " \
13
13
  "Use when: after editing files, before committing, to catch syntax errors and Rails-specific issues. " \
14
- "Pass files:[\"app/models/user.rb\"], use level:\"rails\" for semantic checks (missing partials, bad column refs, orphaned routes)."
14
+ "Pass files:[\"app/models/user.rb\"], use level:\"rails\" for semantic checks (missing partials, route helpers, " \
15
+ "column refs in validates/permit/callbacks)."
15
16
 
16
17
  def self.max_files
17
18
  RailsAiContext.configuration.max_validate_files
@@ -27,7 +28,7 @@ module RailsAiContext
27
28
  level: {
28
29
  type: "string",
29
30
  enum: %w[syntax rails],
30
- description: "Validation level. syntax: check syntax only (default, fast). rails: syntax + semantic checks (partial existence, route helpers, column references)."
31
+ description: "Validation level. syntax: check syntax only (default, fast). rails: syntax + semantic checks (partial existence, route helpers, column refs in validates/permit/callbacks)."
31
32
  }
32
33
  },
33
34
  required: %w[files]
@@ -398,14 +399,14 @@ module RailsAiContext
398
399
  args.each do |arg|
399
400
  case arg
400
401
  when Prism::StringNode
401
- @render_calls << { name: arg.unescaped, line: node.location.start_line }
402
+ @render_calls << { name: arg.unescaped, line: node.location.start_line, explicit_partial: false }
402
403
  when Prism::KeywordHashNode
403
404
  arg.elements.each do |elem|
404
405
  next unless elem.is_a?(Prism::AssocNode)
405
406
  key = elem.key
406
407
  val = elem.value
407
408
  if key.is_a?(Prism::SymbolNode) && key.value == "partial" && val.is_a?(Prism::StringNode)
408
- @render_calls << { name: val.unescaped, line: node.location.start_line }
409
+ @render_calls << { name: val.unescaped, line: node.location.start_line, explicit_partial: true }
409
410
  end
410
411
  end
411
412
  end
@@ -494,6 +495,7 @@ module RailsAiContext
494
495
  elsif file.end_with?(".rb")
495
496
  if visitor
496
497
  warnings.concat(check_route_helpers_ast(visitor, context))
498
+ warnings.concat(check_partial_existence_ast(file, visitor, qualified_only: true))
497
499
  warnings.concat(check_column_references_ast(file, visitor, context))
498
500
  warnings.concat(check_strong_params_ast(file, visitor, context))
499
501
  warnings.concat(check_callback_existence_ast(file, visitor, context))
@@ -536,19 +538,31 @@ module RailsAiContext
536
538
 
537
539
  # ── CHECK 1: Partial existence (AST) ─────────────────────────────
538
540
 
539
- private_class_method def self.check_partial_existence_ast(file, visitor)
541
+ # qualified_only: a Ruby file has no view directory to resolve bare
542
+ # partial names against, so only "dir/name" references are checkable.
543
+ private_class_method def self.check_partial_existence_ast(file, visitor, qualified_only: false)
540
544
  warnings = []
541
545
  visitor.render_calls.each do |rc|
542
546
  ref = rc[:name]
543
547
  next if ref.include?("@") || ref.include?("#") || ref.include?("{")
548
+ next if qualified_only && !ref.include?("/")
544
549
  possible = resolve_partial_paths(file, ref)
550
+ # In a Ruby file, bare `render "dir/name"` renders the TEMPLATE
551
+ # (only `partial:` has partial semantics there), so accept either.
552
+ template_semantics = qualified_only && !rc[:explicit_partial]
553
+ possible += resolve_template_paths(ref) if template_semantics
545
554
  unless possible.any? { |p| File.exist?(File.join(rails_app.root, "app", "views", p)) }
546
- warnings << "render \"#{ref}\" - partial not found (checked: #{possible.first(2).join(', ')})"
555
+ label = template_semantics ? "template/partial not found" : "partial not found"
556
+ warnings << "render \"#{ref}\" - #{label} (checked: #{possible.first(2).join(', ')})"
547
557
  end
548
558
  end
549
559
  warnings
550
560
  end
551
561
 
562
+ private_class_method def self.resolve_template_paths(ref)
563
+ %w[.html.erb .erb .turbo_stream.erb .json.jbuilder].map { |ext| "#{ref}#{ext}" }
564
+ end
565
+
552
566
  # Regex fallback for non-Prism environments
553
567
  private_class_method def self.check_partial_existence_regex(file, content)
554
568
  warnings = []
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.16.0"
4
+ VERSION = "5.16.2"
5
5
  end
@@ -137,8 +137,32 @@ module RailsAiContext
137
137
  views_dir = Rails.root.join("app", "views")
138
138
  full_path = views_dir.join(path)
139
139
 
140
- unless File.exist?(full_path)
141
- content = JSON.pretty_generate(error: "View not found: #{path}")
140
+ # Extension-less lookup: "posts/index" resolves to the template file
141
+ # itself (posts/index.html.erb) so callers don't have to know the
142
+ # format/handler suffix chain. File.file? so a same-named directory
143
+ # (e.g. a posts/index/ partial dir) doesn't defeat the fallback.
144
+ unless File.file?(full_path)
145
+ dir = File.dirname(path)
146
+ base = File.basename(path)
147
+ parent = dir == "." ? views_dir.to_s : views_dir.join(dir).to_s
148
+ candidates = begin
149
+ Dir.children(parent).select { |e| e.start_with?("#{base}.") && File.file?(File.join(parent, e)) }
150
+ rescue SystemCallError
151
+ []
152
+ end
153
+ # Never let the fallback land on a sensitive file: "master" matching
154
+ # master.key would turn the not-found/not-allowed message split into
155
+ # an existence oracle for secrets placed under app/views.
156
+ candidates.reject! { |e| RailsAiContext::Tools::BaseTool.send(:sensitive_file?, dir == "." ? e : File.join(dir, e)) }
157
+ if candidates.any?
158
+ chosen = candidates.min_by { |e| [ e.end_with?(".html.erb") ? 0 : 1, e ] }
159
+ path = dir == "." ? chosen : File.join(dir, chosen)
160
+ full_path = views_dir.join(path)
161
+ end
162
+ end
163
+
164
+ unless File.file?(full_path)
165
+ content = JSON.pretty_generate(error: "View not found: #{path}. Paths are relative to app/views; the extension is optional (posts/index and posts/index.html.erb both resolve).")
142
166
  return [ { uri: uri, mimeType: "application/json", text: content } ]
143
167
  end
144
168
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.16.0
4
+ version: 5.16.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine