rails-ai-context 5.2.0 → 5.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1dd01df89a380a86e14436ae9275c5db3f88b0e4fc56ea6df7d308f2c468472
4
- data.tar.gz: 6f2efe8319fa533b8ea19cc0ace32460b24cb9b96873af0956c4b126041cd14b
3
+ metadata.gz: 43e8a71c793404bd950627b8f3319b4e909a0ba7a2cfe937a8d1027e2ede4af0
4
+ data.tar.gz: f296960edc1f3f336e90b9549618fd4b48d51d31330d95a65de9304ba47aa0af
5
5
  SHA512:
6
- metadata.gz: 1447735cb99e717f1fa43a1fa54cc89fe1d6e0a836fcf156a7a66cede53c321328fbe4088a0c72103d91fe9c49de88f537cb59cddcbe1782773416892f76c728
7
- data.tar.gz: 57b9e93748454c414563d453661eb4b51f94a063f15ac12a7073d6695b3cbc1e7fb1db323be3972ac1f830c1b21d73fcc65dc8fc1d5f170c8787cd9c8031881d
6
+ metadata.gz: bc10e825ef820ad955e89ded454469104160f954b967958866876794b1c114d9b708564e99781f1252cf4faa683d468bf5143823ee3418080de43ef745508ab2
7
+ data.tar.gz: 127d4159c5d88da79ae7fcb7389ffb84cca28621e0181da7b7e495e6af6fe0f8c0073b37dff3087a67be78c236e6f09e36aaef82e070461406e8a2027d88d2a8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ 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.4.0] — 2026-04-08
9
+
10
+ ### Added — Phase 3: Dynamic VFS & Live Resource Architecture (Ground Truth Engine Blueprint #39)
11
+
12
+ Live Virtual File System replaces static resource handling. Every MCP resource is introspected fresh on every request — zero stale data.
13
+
14
+ - **VFS URI Dispatcher** (`lib/rails_ai_context/vfs.rb`) — Pattern-matched routing for `rails-ai-context://` URIs. Resolves models, controllers, controller actions, views, and routes. Each call introspects fresh. Path traversal protection for view reads.
15
+
16
+ - **4 new MCP Resource Templates:**
17
+ - `rails-ai-context://controllers/{name}` — controller details with actions, filters, strong params
18
+ - `rails-ai-context://controllers/{name}/{action}` — action source code and applicable filters
19
+ - `rails-ai-context://views/{path}` — view template content (path traversal protected)
20
+ - `rails-ai-context://routes/{controller}` — live route map filtered by controller name
21
+
22
+ - **MCP Controller** (`app/controllers/rails_ai_context/mcp_controller.rb`) — Native Rails controller for Streamable HTTP transport. Alternative to Rack middleware — integrates with Rails routing, authentication, and middleware stack. Mount via `mount RailsAiContext::Engine, at: "/mcp"`.
23
+
24
+ - **output_schema on all 38 tools** — Default `MCP::Tool::OutputSchema` set via `BaseTool.inherited` hook. Every tool now declares its output format in the MCP protocol. Individual tools can override with custom schemas.
25
+
26
+ - **Instrumentation** (`lib/rails_ai_context/instrumentation.rb`) — Bridges MCP gem instrumentation to `ActiveSupport::Notifications`. Events: `rails_ai_context.tools.call`, `rails_ai_context.resources.read`, etc. Subscribe with standard Rails notification patterns.
27
+
28
+ - **Server instructions** — MCP server now includes `instructions:` field describing the ground truth engine capabilities.
29
+
30
+ - **Enhanced LiveReload** — Full cache sweep on file changes via `reset_all_caches!` (includes AST, tool, and fingerprint caches).
31
+
32
+ - **82 new specs** covering VFS resolution (models, controllers, actions, views, routes), instrumentation callback, McpController (thread safety, delegation, subclass isolation), resource templates (5 total), output_schema on all 38 tools, and server configuration.
33
+
34
+ ## [5.3.0] — 2026-04-07
35
+
36
+ ### Added — Phase 2: Cross-Tool Semantic Hydration (Ground Truth Engine Blueprint #38)
37
+
38
+ Controller and view tools now automatically inject schema hints for referenced models, eliminating the need for follow-up tool calls.
39
+
40
+ - **SchemaHint** (`lib/rails_ai_context/schema_hint.rb`) — Immutable `Data.define` value object carrying model ground truth: table, columns, associations, validations, primary key, and `[VERIFIED]`/`[INFERRED]` confidence tag.
41
+
42
+ - **HydrationResult** — Wraps hints + warnings for downstream formatting.
43
+
44
+ - **SchemaHintBuilder** (`lib/rails_ai_context/hydrators/schema_hint_builder.rb`) — Resolves model names to `SchemaHint` objects from cached introspection context. Case-insensitive lookup, batch builder with configurable cap.
45
+
46
+ - **HydrationFormatter** (`lib/rails_ai_context/hydrators/hydration_formatter.rb`) — Renders `SchemaHint` objects as compact Markdown `## Schema Hints` sections with columns (capped at 10), associations, and validations.
47
+
48
+ - **ControllerHydrator** (`lib/rails_ai_context/hydrators/controller_hydrator.rb`) — Parses controller source via Prism AST to detect model references (constant receivers, `params.require` keys, ivar writes), then builds schema hints.
49
+
50
+ - **ViewHydrator** (`lib/rails_ai_context/hydrators/view_hydrator.rb`) — Maps instance variable names to models by convention (`@post` → `Post`, `@posts` → `Post`). Filters framework ivars (page, query, flash, etc.).
51
+
52
+ - **ModelReferenceListener** (`lib/rails_ai_context/introspectors/listeners/model_reference_listener.rb`) — Prism Dispatcher listener for controller-specific model detection. Not registered in `LISTENER_MAP` — used standalone by `ControllerHydrator`.
53
+
54
+ - **Tool integrations:**
55
+ - `GetControllers` — schema hints injected into both action source and controller overview
56
+ - `GetContext` — hydrates combined controller+view ivars in action context mode
57
+ - `GetView` — hydrates instance variables from view templates in standard detail
58
+
59
+ - **Configuration:** `hydration_enabled` (default: true), `hydration_max_hints` (default: 5). Both YAML-configurable.
60
+
61
+ - **65 new specs** covering SchemaHint, HydrationResult, SchemaHintBuilder, HydrationFormatter, ModelReferenceListener, ControllerHydrator, ViewHydrator, tool-level hydration integration (GetControllers, GetView), and configuration (defaults, YAML loading, max_hints propagation).
62
+
8
63
  ## [5.2.0] — 2026-04-07
9
64
 
10
65
  ### Added — Phase 1: Prism AST Foundation (Ground Truth Engine Blueprint #36)
data/README.md CHANGED
@@ -17,9 +17,10 @@
17
17
  [![Downloads](https://img.shields.io/gem/dt/rails-ai-context?color=blue)](https://rubygems.org/gems/rails-ai-context)
18
18
  [![CI](https://github.com/crisnahine/rails-ai-context/actions/workflows/ci.yml/badge.svg)](https://github.com/crisnahine/rails-ai-context/actions)
19
19
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-green)](https://registry.modelcontextprotocol.io)
20
+ <br>
20
21
  [![Ruby](https://img.shields.io/badge/Ruby-3.2%20%7C%203.3%20%7C%203.4-CC342D)](https://github.com/crisnahine/rails-ai-context)
21
22
  [![Rails](https://img.shields.io/badge/Rails-7.1%20%7C%207.2%20%7C%208.0-CC0000)](https://github.com/crisnahine/rails-ai-context)
22
- [![Tests](https://img.shields.io/badge/Tests-1668%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
23
+ [![Tests](https://img.shields.io/badge/Tests-1813%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
23
24
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
24
25
 
25
26
  </div>
@@ -66,7 +67,7 @@ rails-ai-context serve # start MCP server
66
67
 
67
68
  </div>
68
69
 
69
- Now your AI doesn't guess — it **asks your app directly.** 38 tools that query your schema, models, routes, controllers, views, and conventions on demand. Model introspection uses Prism AST parsing — every result carries a `[VERIFIED]` or `[INFERRED]` confidence tag so AI knows what's ground truth and what needs runtime checking.
70
+ Now your AI doesn't guess — it **asks your app directly.** 38 tools and 5 resource templates that query your schema, models, routes, controllers, views, and conventions on demand. Model introspection uses Prism AST parsing — every result carries a `[VERIFIED]` or `[INFERRED]` confidence tag so AI knows what's ground truth and what needs runtime checking.
70
71
 
71
72
  <br>
72
73
 
@@ -122,13 +123,13 @@ Compare what AI outputs with and without these tools wired in. The difference is
122
123
 
123
124
  <br>
124
125
 
125
- ## Two ways to use it
126
+ ## Three ways to use it
126
127
 
127
128
  <table>
128
129
  <tr>
129
- <td width="50%">
130
+ <td width="33%">
130
131
 
131
- ### MCP Server
132
+ ### MCP Server (stdio)
132
133
 
133
134
  AI calls tools directly via the protocol. Auto-discovered through `.mcp.json`.
134
135
 
@@ -143,7 +144,21 @@ rails ai:serve
143
144
  ```
144
145
 
145
146
  </td>
146
- <td width="50%">
147
+ <td width="33%">
148
+
149
+ ### MCP Server (HTTP)
150
+
151
+ Mount inside your Rails app — inherits routing, auth, and middleware.
152
+
153
+ ```ruby
154
+ # config/routes.rb
155
+ mount RailsAiContext::Engine, at: "/mcp"
156
+ ```
157
+
158
+ Native Rails controller transport. No separate process needed.
159
+
160
+ </td>
161
+ <td width="33%">
147
162
 
148
163
  ### CLI
149
164
 
@@ -342,6 +357,22 @@ Every tool is **read-only** and returns data verified against your actual app
342
357
 
343
358
  <br>
344
359
 
360
+ ## Live Resources (VFS)
361
+
362
+ AI clients can also read structured data through **resource templates** — `rails-ai-context://` URIs that introspect fresh on every request. Zero stale data.
363
+
364
+ | Resource Template | What it returns |
365
+ |:------------------|:---------------|
366
+ | `rails-ai-context://controllers/{name}` | Actions, inherited filters, strong params |
367
+ | `rails-ai-context://controllers/{name}/{action}` | Action source code with applicable filters |
368
+ | `rails-ai-context://views/{path}` | View template content (path traversal protected) |
369
+ | `rails-ai-context://routes/{controller}` | Live route map filtered by controller |
370
+ | `rails://models/{name}` | Per-model details: associations, validations, schema |
371
+
372
+ Plus 9 static resources (schema, routes, conventions, gems, controllers, config, tests, migrations, engines) that AI clients read directly.
373
+
374
+ <br>
375
+
345
376
  ## Anti-Hallucination Protocol
346
377
 
347
378
  Every generated context file ships with **6 rules that force AI verification** before writing code. The protocol targets the exact cognitive failures that produce confident-wrong code: statistical priors overriding observed facts, pattern completion beating verification, stale context lies.
@@ -376,14 +407,15 @@ Enabled by default. Disable with `config.anti_hallucination_rules = false` if yo
376
407
  ┌─────────────────────────────────────────────────────────┐
377
408
  │ rails-ai-context │
378
409
  │ Prism AST parsing. Cached. Confidence-tagged results. │
410
+ │ VFS: rails-ai-context:// URIs introspected fresh. │
379
411
  └────────┬──────────────────┬──────────────┬──────────────┘
380
412
  │ │ │
381
413
  ▼ ▼ ▼
382
414
  ┌──────────────────┐ ┌────────────┐ ┌────────────────────┐
383
415
  │ Static Files │ │ MCP Server │ │ CLI Tools │
384
416
  │ CLAUDE.md │ │ 38 tools │ │ Same 38 tools │
385
- │ .cursor/rules/ │ │ stdio/HTTP │ │ No server needed │
386
- │ .github/instr... │ │ .mcp.json │ │ rails 'ai:tool[X]' │
417
+ │ .cursor/rules/ │ │ 5 templates│ │ No server needed │
418
+ │ .github/instr... │ │ stdio/HTTP │ │ rails 'ai:tool[X]' │
387
419
  └──────────────────┘ └────────────┘ └────────────────────┘
388
420
  ```
389
421
 
@@ -458,6 +490,20 @@ end
458
490
 
459
491
  <br>
460
492
 
493
+ ## Observability
494
+
495
+ Every MCP tool call and resource read fires an `ActiveSupport::Notifications` event. Subscribe with standard Rails patterns:
496
+
497
+ ```ruby
498
+ ActiveSupport::Notifications.subscribe("rails_ai_context.tools.call") do |event|
499
+ Rails.logger.info "[MCP] #{event.payload[:method]} — #{event.duration}ms"
500
+ end
501
+ ```
502
+
503
+ Events: `rails_ai_context.tools.call`, `rails_ai_context.resources.read`, and more. All 38 tools declare `output_schema` in the MCP protocol, so clients know the response format before calling.
504
+
505
+ <br>
506
+
461
507
  ## Requirements
462
508
 
463
509
  - **Ruby** >= 3.2 &nbsp;&nbsp; **Rails** >= 7.1
@@ -472,7 +518,7 @@ end
472
518
  ## About
473
519
 
474
520
  Built by a Rails developer with 10+ years of production experience.<br>
475
- 1668 tests. 38 tools. 31 introspectors. Standalone or in-Gemfile.<br>
521
+ 1813 tests. 38 tools. 5 resource templates. 31 introspectors. Standalone or in-Gemfile.<br>
476
522
  MIT licensed. [Contributions welcome.](CONTRIBUTING.md)
477
523
 
478
524
  <br>
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # Rails controller for serving MCP over Streamable HTTP.
5
+ # Alternative to the Rack middleware — integrates with Rails routing,
6
+ # authentication, and middleware stack.
7
+ #
8
+ # Mount in routes: mount RailsAiContext::Engine, at: "/mcp"
9
+ class McpController < ActionController::API
10
+ 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]
15
+ end
16
+
17
+ class << self
18
+ # Class-level memoization — transport persists across requests.
19
+ # Thread-safe: MCP::Server and transport are stateless for reads.
20
+ def mcp_transport
21
+ @mcp_transport || @transport_mutex.synchronize do
22
+ @mcp_transport ||= begin
23
+ server = RailsAiContext::Server.new(Rails.application, transport: :http).build
24
+ MCP::Server::Transports::StreamableHTTPTransport.new(server)
25
+ end
26
+ end
27
+ end
28
+
29
+ def reset_transport!
30
+ @transport_mutex.synchronize { @mcp_transport = nil }
31
+ end
32
+
33
+ private
34
+
35
+ def inherited(subclass)
36
+ super
37
+ subclass.instance_variable_set(:@transport_mutex, Mutex.new)
38
+ end
39
+ end
40
+
41
+ @transport_mutex = Mutex.new
42
+ end
43
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsAiContext::Engine.routes.draw do
4
+ match "/", to: "mcp#handle", via: :all
5
+ end
data/docs/GUIDE.md CHANGED
@@ -431,7 +431,7 @@ rails_get_routes(detail: "standard", limit: 20, offset: 100)
431
431
 
432
432
  ### rails_get_controllers
433
433
 
434
- Returns controller details: actions, filters, strong params, concerns.
434
+ Returns controller details: actions, filters, strong params, concerns. Automatically includes **Schema Hints** for models referenced in the controller (via Prism AST detection).
435
435
 
436
436
  **Parameters:**
437
437
 
@@ -549,7 +549,7 @@ rails_get_stimulus(detail: "full")
549
549
 
550
550
  ### rails_get_view
551
551
 
552
- Returns view template contents, partials, and Stimulus controller references.
552
+ Returns view template contents, partials, and Stimulus controller references. In standard detail, includes **Schema Hints** for models inferred from instance variables.
553
553
 
554
554
  **Parameters:**
555
555
 
@@ -946,7 +946,7 @@ rails_get_turbo_map(controller: "messages", detail: "full")
946
946
 
947
947
  ### rails_get_context
948
948
 
949
- Get cross-layer context in a single call — combines schema, model, controller, routes, views, stimulus, and tests. Use when you need full context for implementing a feature or modifying an action.
949
+ Get cross-layer context in a single call — combines schema, model, controller, routes, views, stimulus, and tests. Automatically includes **Schema Hints** for models referenced in controller/view code. Use when you need full context for implementing a feature or modifying an action.
950
950
 
951
951
  **Parameters:**
952
952
 
@@ -1010,6 +1010,17 @@ In addition to tools, the gem registers static MCP resources that AI clients can
1010
1010
  | `rails://engines` | Mounted engines with paths and descriptions (JSON) |
1011
1011
  | `rails://models/{name}` | Per-model details (resource template) |
1012
1012
 
1013
+ ### Dynamic Resource Templates (VFS)
1014
+
1015
+ Live resources introspected fresh on every request — zero stale data:
1016
+
1017
+ | Resource Template | Description |
1018
+ |-------------------|-------------|
1019
+ | `rails-ai-context://controllers/{name}` | Controller details with actions, filters, strong params |
1020
+ | `rails-ai-context://controllers/{name}/{action}` | Specific action source code and applicable filters |
1021
+ | `rails-ai-context://views/{path}` | View template content (path traversal protected) |
1022
+ | `rails-ai-context://routes` | Live route map (optionally filter by controller) |
1023
+
1013
1024
  ---
1014
1025
 
1015
1026
  ## MCP Server Setup
@@ -1126,6 +1137,17 @@ end
1126
1137
 
1127
1138
  Both transports are **read-only** — they expose the same 38 tools and never modify your app.
1128
1139
 
1140
+ ### Controller Transport (Alternative)
1141
+
1142
+ For tighter Rails integration (authentication, routing, middleware stack), mount the engine instead of using Rack middleware:
1143
+
1144
+ ```ruby
1145
+ # config/routes.rb
1146
+ mount RailsAiContext::Engine, at: "/mcp"
1147
+ ```
1148
+
1149
+ This provides a native Rails controller (`RailsAiContext::McpController`) that delegates to the Streamable HTTP transport.
1150
+
1129
1151
  ---
1130
1152
 
1131
1153
  ## Configuration — All Options
@@ -1275,6 +1297,8 @@ end
1275
1297
  | `server_version` | String | gem version | MCP server version |
1276
1298
  | `generate_root_files` | Boolean | `true` | Generate root files (CLAUDE.md, etc.) — set `false` for split rules only |
1277
1299
  | `anti_hallucination_rules` | Boolean | `true` | Embed 6-rule Anti-Hallucination Protocol in generated context files — set `false` to skip |
1300
+ | `hydration_enabled` | Boolean | `true` | Inject schema hints into controller/view tool responses |
1301
+ | `hydration_max_hints` | Integer | `5` | Max schema hints per tool response |
1278
1302
  | `max_file_size` | Integer | `5_000_000` | Per-file read limit for tools (5MB) |
1279
1303
  | `max_test_file_size` | Integer | `1_000_000` | Test file read limit (1MB) |
1280
1304
  | `max_schema_file_size` | Integer | `10_000_000` | schema.rb / structure.sql parse limit (10MB) |
@@ -23,6 +23,7 @@ module RailsAiContext
23
23
  max_view_file_size max_search_results max_validate_files
24
24
  query_timeout query_row_limit query_redacted_columns allow_query_in_production
25
25
  log_lines introspectors
26
+ hydration_enabled hydration_max_hints
26
27
  ].freeze
27
28
 
28
29
  # Load configuration from a YAML file, applying values to the current config instance.
@@ -211,6 +212,10 @@ module RailsAiContext
211
212
  # Log reading settings (rails_read_logs)
212
213
  attr_accessor :log_lines # Default lines to tail (default: 50)
213
214
 
215
+ # Hydration: inject schema hints into controller/view tool responses
216
+ attr_accessor :hydration_enabled # Enable/disable hydration (default: true)
217
+ attr_accessor :hydration_max_hints # Max schema hints per response (default: 5)
218
+
214
219
  def initialize
215
220
  @server_name = "rails-ai-context"
216
221
  @introspectors = PRESETS[:full].dup
@@ -267,6 +272,8 @@ module RailsAiContext
267
272
  ]
268
273
  @allow_query_in_production = false
269
274
  @log_lines = 50
275
+ @hydration_enabled = true
276
+ @hydration_max_hints = 5
270
277
  end
271
278
 
272
279
  def preset=(name)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # Wraps hydration output: hints for detected models + any warnings.
5
+ HydrationResult = Data.define(:hints, :warnings) do
6
+ def initialize(hints: [], warnings: [])
7
+ super
8
+ end
9
+
10
+ def any?
11
+ hints.any?
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Hydrators
5
+ # Parses a controller source file via Prism AST, detects model
6
+ # references, and builds SchemaHint objects for each detected model.
7
+ class ControllerHydrator
8
+ # Hydrate a controller file with schema hints.
9
+ # Returns a HydrationResult with hints for all detected models.
10
+ def self.call(source_path, context:)
11
+ return HydrationResult.new unless source_path && File.exist?(source_path)
12
+ return HydrationResult.new if File.size(source_path) > RailsAiContext.configuration.max_file_size
13
+
14
+ model_names = detect_model_references(source_path)
15
+ return HydrationResult.new if model_names.empty?
16
+
17
+ hints = SchemaHintBuilder.build_many(model_names, context: context, max: RailsAiContext.configuration.hydration_max_hints)
18
+
19
+ warnings = []
20
+ unresolved = model_names - hints.map(&:model_name)
21
+ unresolved.each do |name|
22
+ warnings << "Model '#{name}' referenced but not found in introspection data"
23
+ end
24
+
25
+ HydrationResult.new(hints: hints, warnings: warnings)
26
+ rescue => e
27
+ $stderr.puts "[rails-ai-context] ControllerHydrator failed: #{e.message}" if ENV["DEBUG"]
28
+ HydrationResult.new
29
+ end
30
+
31
+ # Detect model names referenced in a controller source file using Prism AST.
32
+ def self.detect_model_references(source_path)
33
+ parse_result = AstCache.parse(source_path)
34
+ dispatcher = Prism::Dispatcher.new
35
+ listener = Introspectors::Listeners::ModelReferenceListener.new
36
+
37
+ events = []
38
+ events << :on_call_node_enter if listener.respond_to?(:on_call_node_enter)
39
+ events << :on_instance_variable_write_node_enter if listener.respond_to?(:on_instance_variable_write_node_enter)
40
+ dispatcher.register(listener, *events)
41
+
42
+ dispatcher.dispatch(parse_result.value)
43
+ listener.results
44
+ rescue => e
45
+ $stderr.puts "[rails-ai-context] detect_model_references failed: #{e.message}" if ENV["DEBUG"]
46
+ []
47
+ end
48
+ private_class_method :detect_model_references
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Hydrators
5
+ # Formats SchemaHint objects into Markdown for tool output.
6
+ class HydrationFormatter
7
+ # Format a HydrationResult into a Markdown section.
8
+ def self.format(hydration_result)
9
+ return "" unless hydration_result&.any?
10
+
11
+ lines = [ "## Schema Hints", "" ]
12
+ hydration_result.hints.each do |hint|
13
+ lines << format_hint(hint)
14
+ lines << ""
15
+ end
16
+
17
+ if hydration_result.warnings.any?
18
+ hydration_result.warnings.each { |w| lines << "_#{w}_" }
19
+ lines << ""
20
+ end
21
+
22
+ lines.join("\n")
23
+ end
24
+
25
+ # Format a single SchemaHint as a compact Markdown block.
26
+ def self.format_hint(hint)
27
+ lines = []
28
+ lines << "### #{hint.model_name} #{hint.confidence}"
29
+ lines << "**Table:** `#{hint.table_name}` (pk: `#{hint.primary_key}`)"
30
+
31
+ if hint.columns.any?
32
+ col_summary = hint.columns.first(10).map { |c|
33
+ "`#{c[:name]}` #{c[:type]}#{c[:null] == false ? ' NOT NULL' : ''}"
34
+ }
35
+ col_summary << "... #{hint.columns.size - 10} more" if hint.columns.size > 10
36
+ lines << "**Columns:** #{col_summary.join(', ')}"
37
+ end
38
+
39
+ if hint.associations.any?
40
+ assoc_list = hint.associations.map { |a| "`#{a[:type]}` :#{a[:name]}" }
41
+ lines << "**Associations:** #{assoc_list.join(', ')}"
42
+ end
43
+
44
+ if hint.validations.any?
45
+ val_list = hint.validations.map { |v|
46
+ attrs = v[:attributes]&.join(", ") || ""
47
+ "#{v[:kind]}(#{attrs})"
48
+ }
49
+ lines << "**Validations:** #{val_list.join(', ')}"
50
+ end
51
+
52
+ lines.join("\n")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Hydrators
5
+ # Builds SchemaHint objects from cached introspection context.
6
+ # Single point of truth for resolving a model name into a
7
+ # structured hydration payload.
8
+ class SchemaHintBuilder
9
+ # Build a SchemaHint for a single model name.
10
+ # Returns nil if the model is not found in context.
11
+ def self.build(model_name, context:)
12
+ models_data = context[:models]
13
+ schema_data = context[:schema]
14
+ return nil unless models_data.is_a?(Hash) && schema_data.is_a?(Hash)
15
+
16
+ # Find model in models context (case-insensitive)
17
+ model_key = models_data.keys.find { |k| k.to_s.casecmp?(model_name) }
18
+ return nil unless model_key
19
+
20
+ model_info = models_data[model_key]
21
+ return nil unless model_info.is_a?(Hash)
22
+
23
+ table_name = model_info[:table_name]
24
+ table_data = schema_data.dig(:tables, table_name) if table_name
25
+
26
+ columns = if table_data
27
+ (table_data[:columns] || []).map do |col|
28
+ { name: col[:name], type: col[:type], null: col[:null] }.compact
29
+ end
30
+ else
31
+ []
32
+ end
33
+
34
+ associations = (model_info[:associations] || []).map do |assoc|
35
+ {
36
+ name: assoc[:name],
37
+ type: assoc[:type],
38
+ class_name: assoc[:class_name],
39
+ foreign_key: assoc[:foreign_key]
40
+ }.compact
41
+ end
42
+
43
+ validations = (model_info[:validations] || []).map do |val|
44
+ {
45
+ kind: val[:kind],
46
+ attributes: val[:attributes]
47
+ }.compact
48
+ end
49
+
50
+ primary_key = table_data&.dig(:primary_key) || "id"
51
+
52
+ # Confidence: verified if we have both model data and schema table
53
+ confidence = table_data ? "[VERIFIED]" : "[INFERRED]"
54
+
55
+ SchemaHint.new(
56
+ model_name: model_key.to_s,
57
+ table_name: table_name.to_s,
58
+ columns: columns,
59
+ associations: associations,
60
+ validations: validations,
61
+ primary_key: primary_key.to_s,
62
+ confidence: confidence
63
+ )
64
+ end
65
+
66
+ # Build SchemaHints for multiple model names.
67
+ # Returns only the ones that resolved successfully.
68
+ def self.build_many(model_names, context:, max: 5)
69
+ model_names.first(max).filter_map { |name| build(name, context: context) }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Hydrators
5
+ # Resolves instance variable references in views to model schema hints.
6
+ # Maps @post → Post, @posts → Post (singularized), etc.
7
+ class ViewHydrator
8
+ # Hydrate view instance variables with schema hints.
9
+ # ivar_names: array of instance variable names (without @) used in a view.
10
+ # Returns a HydrationResult with hints for resolved models.
11
+ def self.call(ivar_names, context:)
12
+ return HydrationResult.new if ivar_names.nil? || ivar_names.empty?
13
+
14
+ model_names = ivar_names.filter_map { |ivar| ivar_to_model_name(ivar) }.uniq
15
+ return HydrationResult.new if model_names.empty?
16
+
17
+ hints = SchemaHintBuilder.build_many(model_names, context: context, max: RailsAiContext.configuration.hydration_max_hints)
18
+
19
+ warnings = []
20
+ unresolved = model_names - hints.map(&:model_name)
21
+ unresolved.each do |name|
22
+ warnings << "@#{name.underscore} used in view but '#{name}' model not found"
23
+ end
24
+
25
+ HydrationResult.new(hints: hints, warnings: warnings)
26
+ rescue => e
27
+ $stderr.puts "[rails-ai-context] ViewHydrator failed: #{e.message}" if ENV["DEBUG"]
28
+ HydrationResult.new
29
+ end
30
+
31
+ # Convert an instance variable name to a model name by convention.
32
+ # @post → "Post", @posts → "Post", @current_user → "User"
33
+ def self.ivar_to_model_name(ivar_name)
34
+ name = ivar_name.to_s
35
+ # Skip framework/common non-model ivars
36
+ return nil if SKIP_IVARS.include?(name)
37
+
38
+ # Singularize and classify: "posts" → "Post", "order_items" → "OrderItem"
39
+ name.singularize.camelize
40
+ rescue StandardError
41
+ nil
42
+ end
43
+ private_class_method :ivar_to_model_name
44
+
45
+ SKIP_IVARS = %w[
46
+ page per_page total_count total_pages
47
+ query search filter sort order
48
+ flash notice alert errors
49
+ breadcrumbs tabs menu
50
+ title description meta
51
+ current_page pagy
52
+ output_buffer virtual_path _request
53
+ ].to_set.freeze
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # Bridges MCP gem instrumentation to ActiveSupport::Notifications.
5
+ # Enables Rails apps to subscribe to MCP events (tool calls, resource reads, etc.).
6
+ module Instrumentation
7
+ EVENT_PREFIX = "rails_ai_context"
8
+
9
+ # Returns a lambda for MCP::Configuration#instrumentation_callback.
10
+ # Instruments each MCP method call as an ActiveSupport::Notifications event.
11
+ def self.callback
12
+ ->(data) {
13
+ return unless defined?(ActiveSupport::Notifications)
14
+
15
+ method = data[:method] || "unknown"
16
+ event_name = "#{EVENT_PREFIX}.#{method.tr("/", ".")}"
17
+
18
+ ActiveSupport::Notifications.instrument(event_name, data)
19
+ }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module RailsAiContext
6
+ module Introspectors
7
+ module Listeners
8
+ # Prism Dispatcher listener that detects model references in controller source.
9
+ # Extracts constant names used as method receivers (Post.find, Post.new),
10
+ # params.require(:post) keys, and instance variable write targets.
11
+ #
12
+ # Not registered in SourceIntrospector::LISTENER_MAP — used standalone
13
+ # by ControllerHydrator since model references are controller-specific.
14
+ class ModelReferenceListener < BaseListener
15
+ attr_reader :constant_references, :require_keys, :ivar_models
16
+
17
+ def initialize
18
+ super
19
+ @constant_references = []
20
+ @require_keys = []
21
+ @ivar_models = {}
22
+ end
23
+
24
+ # Detect: Post.find, Post.where, Post.new, Post.create, etc.
25
+ # Detect: params.require(:post)
26
+ def on_call_node_enter(node)
27
+ extract_constant_receiver(node)
28
+ extract_params_require(node)
29
+ end
30
+
31
+ # Detect: @post = Post.new(...), @post = Post.find(...)
32
+ def on_instance_variable_write_node_enter(node)
33
+ extract_ivar_model(node)
34
+ end
35
+
36
+ def results
37
+ model_names = Set.new
38
+
39
+ # Constant receivers: Post.find → "Post"
40
+ @constant_references.each { |name| model_names << name }
41
+
42
+ # params.require(:post) → "Post"
43
+ @require_keys.each { |key| model_names << key.to_s.classify }
44
+
45
+ # @post = Post.new → "Post"
46
+ @ivar_models.each_value { |name| model_names << name }
47
+
48
+ model_names.reject { |n| framework_constant?(n) }.sort
49
+ end
50
+
51
+ private
52
+
53
+ def extract_constant_receiver(node)
54
+ receiver = node.receiver
55
+ return unless receiver
56
+
57
+ case receiver
58
+ when Prism::ConstantReadNode
59
+ @constant_references << receiver.name.to_s
60
+ when Prism::ConstantPathNode
61
+ # Handles Namespaced::Model.find
62
+ @constant_references << constant_path_string(receiver)
63
+ end
64
+ end
65
+
66
+ def extract_params_require(node)
67
+ return unless node.name == :require
68
+ receiver = node.receiver
69
+ return unless receiver.is_a?(Prism::CallNode) && receiver.name == :params && receiver.receiver.nil?
70
+
71
+ arg = node.arguments&.arguments&.first
72
+ case arg
73
+ when Prism::SymbolNode then @require_keys << arg.value
74
+ when Prism::StringNode then @require_keys << arg.unescaped
75
+ end
76
+ end
77
+
78
+ def extract_ivar_model(node)
79
+ value = node.value
80
+ return unless value.is_a?(Prism::CallNode)
81
+
82
+ receiver = value.receiver
83
+ name = case receiver
84
+ when Prism::ConstantReadNode then receiver.name.to_s
85
+ when Prism::ConstantPathNode then constant_path_string(receiver)
86
+ end
87
+ return unless name
88
+
89
+ @ivar_models[node.name.to_s] = name
90
+ end
91
+
92
+ FRAMEWORK_CONSTANTS = %w[
93
+ ApplicationController ActionController ActionDispatch
94
+ ActiveRecord ActiveSupport Rails ApplicationRecord
95
+ ActionView ActionMailer ActiveJob ActiveStorage
96
+ ActionCable ActionMailbox ActionText Turbo
97
+ ].to_set.freeze
98
+
99
+ def framework_constant?(name)
100
+ root = name.split("::").first
101
+ FRAMEWORK_CONSTANTS.include?(root)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -53,7 +53,7 @@ module RailsAiContext
53
53
 
54
54
  @last_fingerprint = Fingerprinter.compute(app)
55
55
 
56
- # Invalidate all tool caches
56
+ # Invalidate all tool caches (includes AstCache.clear)
57
57
  Tools::BaseTool.reset_all_caches!
58
58
 
59
59
  # Build a human-readable change summary
@@ -70,6 +70,34 @@ module RailsAiContext
70
70
  mime_type: "application/json"
71
71
  ).freeze
72
72
 
73
+ CONTROLLER_TEMPLATE = MCP::ResourceTemplate.new(
74
+ uri_template: "rails-ai-context://controllers/{name}",
75
+ name: "Controller Details",
76
+ description: "Controller with actions, filters, strong params, and schema hints",
77
+ mime_type: "application/json"
78
+ ).freeze
79
+
80
+ CONTROLLER_ACTION_TEMPLATE = MCP::ResourceTemplate.new(
81
+ uri_template: "rails-ai-context://controllers/{name}/{action}",
82
+ name: "Controller Action",
83
+ description: "Source code and metadata for a specific controller action",
84
+ mime_type: "application/json"
85
+ ).freeze
86
+
87
+ VIEW_TEMPLATE = MCP::ResourceTemplate.new(
88
+ uri_template: "rails-ai-context://views/{path}",
89
+ name: "View Template",
90
+ description: "View template content for a specific path relative to app/views",
91
+ mime_type: "text/html"
92
+ ).freeze
93
+
94
+ ROUTES_TEMPLATE = MCP::ResourceTemplate.new(
95
+ uri_template: "rails-ai-context://routes/{controller}",
96
+ name: "Live Routes",
97
+ description: "Application routes introspected fresh on each request, optionally filtered by controller name",
98
+ mime_type: "application/json"
99
+ ).freeze
100
+
73
101
  class << self
74
102
  def static_resources
75
103
  STATIC_RESOURCES.map do |uri, meta|
@@ -83,7 +111,7 @@ module RailsAiContext
83
111
  end
84
112
 
85
113
  def resource_templates
86
- [ MODEL_TEMPLATE ]
114
+ [ MODEL_TEMPLATE, CONTROLLER_TEMPLATE, CONTROLLER_ACTION_TEMPLATE, VIEW_TEMPLATE, ROUTES_TEMPLATE ]
87
115
  end
88
116
 
89
117
  def register(server)
@@ -100,6 +128,11 @@ module RailsAiContext
100
128
 
101
129
  def handle_read(params)
102
130
  uri = params[:uri]
131
+
132
+ # Delegate rails-ai-context:// URIs to the VFS dispatcher
133
+ return VFS.resolve(uri) if uri.start_with?("#{VFS::SCHEME}://")
134
+
135
+ # Legacy rails:// URI handling
103
136
  context = RailsAiContext.introspect
104
137
 
105
138
  if STATIC_RESOURCES.key?(uri)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # Structured hydration payload representing a model's ground truth.
5
+ # Used by hydrators to inject cross-tool context into controller
6
+ # and view tool responses. Immutable value object via Data.define.
7
+ SchemaHint = Data.define(
8
+ :model_name, # "Post"
9
+ :table_name, # "posts"
10
+ :columns, # [{name: "title", type: "string", null: false}, ...]
11
+ :associations, # [{name: "comments", type: "has_many", class_name: "Comment"}, ...]
12
+ :validations, # [{kind: "presence", attributes: ["title"]}, ...]
13
+ :primary_key, # "id"
14
+ :confidence # "[VERIFIED]" or "[INFERRED]"
15
+ ) do
16
+ def verified?
17
+ confidence == "[VERIFIED]"
18
+ end
19
+
20
+ def column_names
21
+ columns.map { |c| c[:name] }
22
+ end
23
+
24
+ def association_names
25
+ associations.map { |a| a[:name] }
26
+ end
27
+ end
28
+ end
@@ -67,11 +67,17 @@ module RailsAiContext
67
67
  end
68
68
  end
69
69
 
70
+ mcp_config = MCP::Configuration.new(
71
+ instrumentation_callback: Instrumentation.callback
72
+ )
73
+
70
74
  server = MCP::Server.new(
71
75
  name: config.server_name,
72
76
  version: config.server_version,
77
+ instructions: "Ground truth engine for Rails apps. Live Prism AST introspection. Zero stale data.",
73
78
  tools: active_tools(config) + validated_custom_tools,
74
- resource_templates: Resources.resource_templates
79
+ resource_templates: Resources.resource_templates,
80
+ configuration: mcp_config
75
81
  )
76
82
 
77
83
  Resources.register(server)
@@ -8,6 +8,32 @@ module RailsAiContext
8
8
  # Inherits from the official MCP::Tool to get schema validation,
9
9
  # annotations, and protocol compliance for free.
10
10
  class BaseTool < MCP::Tool
11
+ # Default output schema for all tools. MCP::Tool.inherited resets
12
+ # @output_schema_value to nil on each subclass, so we re-set it
13
+ # via our own inherited hook.
14
+ DEFAULT_OUTPUT_SCHEMA = MCP::Tool::OutputSchema.new(
15
+ type: "object",
16
+ properties: {
17
+ content: {
18
+ type: "array",
19
+ items: {
20
+ type: "object",
21
+ properties: {
22
+ type: { type: "string", enum: [ "text" ] },
23
+ text: { type: "string", description: "Tool response as Markdown-formatted text" }
24
+ },
25
+ required: [ "type", "text" ]
26
+ }
27
+ }
28
+ },
29
+ required: [ "content" ]
30
+ ).freeze
31
+
32
+ def self.inherited(subclass)
33
+ super
34
+ subclass.instance_variable_set(:@output_schema_value, DEFAULT_OUTPUT_SCHEMA)
35
+ end
36
+
11
37
  # Shared cache across all tool subclasses, protected by a Mutex
12
38
  # for thread safety in multi-threaded servers (e.g., Puma).
13
39
  SHARED_CACHE = { mutex: Mutex.new }
@@ -109,6 +109,17 @@ module RailsAiContext
109
109
  ivar_check = cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: other_templates)
110
110
  lines << "" << ivar_check if ivar_check
111
111
 
112
+ # Hydrate: inject schema hints for models referenced in controller + view ivars
113
+ if RailsAiContext.configuration.hydration_enabled
114
+ all_ivars = (ctrl_ivars | view_ivars).to_a
115
+ hydration = Hydrators::ViewHydrator.call(all_ivars, context: cached_context)
116
+ hydration_text = Hydrators::HydrationFormatter.format(hydration)
117
+ # Skip if get_controllers already injected schema hints
118
+ unless hydration_text.empty? || lines.any? { |l| l.include?("## Schema Hints") }
119
+ lines << "" << hydration_text
120
+ end
121
+ end
122
+
112
123
  text_response(lines.join("\n"))
113
124
  rescue => e
114
125
  text_response("Error assembling context: #{e.message}")
@@ -285,6 +285,13 @@ module RailsAiContext
285
285
  end
286
286
  end
287
287
 
288
+ # Hydrate with schema hints for models referenced in this action
289
+ if RailsAiContext.configuration.hydration_enabled
290
+ hydration = Hydrators::ControllerHydrator.call(source_path.to_s, context: cached_context)
291
+ hydration_text = Hydrators::HydrationFormatter.format(hydration)
292
+ lines << "" << hydration_text unless hydration_text.empty?
293
+ end
294
+
288
295
  lines.join("\n")
289
296
  end
290
297
 
@@ -519,6 +526,14 @@ module RailsAiContext
519
526
  info[:turbo_stream_actions].each { |a| lines << "- `#{a}`" }
520
527
  end
521
528
 
529
+ # Hydrate with schema hints for models referenced in this controller
530
+ if RailsAiContext.configuration.hydration_enabled
531
+ source_path = Rails.root.join("app", "controllers", "#{name.underscore}.rb")
532
+ hydration = Hydrators::ControllerHydrator.call(source_path.to_s, context: cached_context)
533
+ hydration_text = Hydrators::HydrationFormatter.format(hydration)
534
+ lines << "" << hydration_text unless hydration_text.empty?
535
+ end
536
+
522
537
  # Cross-reference hints
523
538
  ctrl_path = name.underscore.delete_suffix("_controller")
524
539
  model_name = ctrl_path.split("/").last.singularize.camelize
@@ -153,6 +153,20 @@ module RailsAiContext
153
153
  end
154
154
  lines << ""
155
155
  end
156
+
157
+ # Hydrate: inject schema hints for models inferred from view instance variables
158
+ if RailsAiContext.configuration.hydration_enabled && controller
159
+ all_ivars = []
160
+ templates.each do |path, _meta|
161
+ content = read_view_content(path)
162
+ content.scan(/@(\w+)/).flatten.each { |v| all_ivars << v }
163
+ end
164
+ all_ivars.uniq!
165
+ hydration = Hydrators::ViewHydrator.call(all_ivars, context: cached_context)
166
+ hydration_text = Hydrators::HydrationFormatter.format(hydration)
167
+ lines << hydration_text << "" unless hydration_text.empty?
168
+ end
169
+
156
170
  text_response(lines.join("\n"))
157
171
 
158
172
  when "full"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.2.0"
4
+ VERSION = "5.4.0"
5
5
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsAiContext
6
+ # Virtual File System — pattern-matched URI routing for MCP resources.
7
+ # Each resolve call introspects fresh (zero stale data).
8
+ module VFS
9
+ SCHEME = "rails-ai-context"
10
+
11
+ PATTERNS = [
12
+ { pattern: %r{\Arails-ai-context://controllers/([^/]+)/([^/]+)\z}, handler: :resolve_controller_action },
13
+ { pattern: %r{\Arails-ai-context://controllers/([^/]+)\z}, handler: :resolve_controller },
14
+ { pattern: %r{\Arails-ai-context://models/(.+)\z}, handler: :resolve_model },
15
+ { pattern: %r{\Arails-ai-context://views/(.+)\z}, handler: :resolve_view },
16
+ { pattern: %r{\Arails-ai-context://routes/(.+)\z}, handler: :resolve_routes }
17
+ ].freeze
18
+
19
+ class << self
20
+ # Resolve a rails-ai-context:// URI to MCP resource content.
21
+ # Returns an array of content hashes: [{uri:, mime_type:, text:}]
22
+ def resolve(uri)
23
+ PATTERNS.each do |entry|
24
+ match = uri.match(entry[:pattern])
25
+ next unless match
26
+
27
+ return send(entry[:handler], uri, *match.captures)
28
+ end
29
+
30
+ raise RailsAiContext::Error, "Unknown VFS URI: #{uri}"
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_model(uri, name)
36
+ context = RailsAiContext.introspect
37
+ models = context[:models] || {}
38
+
39
+ # Case-insensitive lookup
40
+ key = models.keys.find { |k| k.to_s.casecmp?(name) } || name
41
+ data = models[key]
42
+
43
+ unless data
44
+ available = models.keys.sort.first(20)
45
+ content = JSON.pretty_generate(error: "Model '#{name}' not found", available: available)
46
+ return [ { uri: uri, mime_type: "application/json", text: content } ]
47
+ end
48
+
49
+ # Enrich with schema columns if available
50
+ table_name = data[:table_name]
51
+ schema = context.dig(:schema, :tables, table_name) if table_name
52
+ enriched = data.merge(schema: schema).compact
53
+
54
+ [ { uri: uri, mime_type: "application/json", text: JSON.pretty_generate(enriched) } ]
55
+ end
56
+
57
+ def resolve_controller(uri, name)
58
+ context = RailsAiContext.introspect
59
+ controllers = context.dig(:controllers, :controllers) || {}
60
+
61
+ # Flexible matching: "posts", "PostsController", "postscontroller"
62
+ input_snake = name.underscore.delete_suffix("_controller")
63
+ key = controllers.keys.find { |k|
64
+ k.underscore.delete_suffix("_controller") == input_snake ||
65
+ k.downcase.delete_suffix("controller") == name.downcase.delete_suffix("controller")
66
+ }
67
+
68
+ unless key
69
+ available = controllers.keys.sort.first(20)
70
+ content = JSON.pretty_generate(error: "Controller '#{name}' not found", available: available)
71
+ return [ { uri: uri, mime_type: "application/json", text: content } ]
72
+ end
73
+
74
+ [ { uri: uri, mime_type: "application/json", text: JSON.pretty_generate(controllers[key]) } ]
75
+ end
76
+
77
+ def resolve_controller_action(uri, controller_name, action_name)
78
+ context = RailsAiContext.introspect
79
+ controllers = context.dig(:controllers, :controllers) || {}
80
+
81
+ input_snake = controller_name.underscore.delete_suffix("_controller")
82
+ key = controllers.keys.find { |k|
83
+ k.underscore.delete_suffix("_controller") == input_snake
84
+ }
85
+
86
+ unless key
87
+ content = JSON.pretty_generate(error: "Controller '#{controller_name}' not found")
88
+ return [ { uri: uri, mime_type: "application/json", text: content } ]
89
+ end
90
+
91
+ info = controllers[key]
92
+ actions = info[:actions] || []
93
+ action = actions.find { |a| a.to_s.casecmp?(action_name) }
94
+
95
+ unless action
96
+ content = JSON.pretty_generate(error: "Action '#{action_name}' not found in #{key}", available: actions)
97
+ return [ { uri: uri, mime_type: "application/json", text: content } ]
98
+ end
99
+
100
+ # Build action-specific data
101
+ action_data = {
102
+ controller: key,
103
+ action: action.to_s,
104
+ filters: (info[:filters] || []).select { |f|
105
+ if f[:only]&.any?
106
+ f[:only].map(&:to_s).include?(action.to_s)
107
+ elsif f[:except]&.any?
108
+ !f[:except].map(&:to_s).include?(action.to_s)
109
+ else
110
+ true
111
+ end
112
+ },
113
+ strong_params: info[:strong_params]
114
+ }.compact
115
+
116
+ [ { uri: uri, mime_type: "application/json", text: JSON.pretty_generate(action_data) } ]
117
+ end
118
+
119
+ def resolve_view(uri, path)
120
+ # Block path traversal
121
+ if path.include?("..") || path.start_with?("/")
122
+ raise RailsAiContext::Error, "Path not allowed: #{path}"
123
+ end
124
+
125
+ views_dir = Rails.root.join("app", "views")
126
+ full_path = views_dir.join(path)
127
+
128
+ unless File.exist?(full_path)
129
+ content = JSON.pretty_generate(error: "View not found: #{path}")
130
+ return [ { uri: uri, mime_type: "application/json", text: content } ]
131
+ end
132
+
133
+ # Verify resolved path is still under views_dir
134
+ unless File.realpath(full_path).start_with?(File.realpath(views_dir))
135
+ raise RailsAiContext::Error, "Path not allowed: #{path}"
136
+ end
137
+
138
+ max_size = RailsAiContext.configuration.max_file_size
139
+ if File.size(full_path) > max_size
140
+ content = JSON.pretty_generate(error: "File too large: #{path} (#{File.size(full_path)} bytes)")
141
+ return [ { uri: uri, mime_type: "application/json", text: content } ]
142
+ end
143
+
144
+ view_content = RailsAiContext::SafeFile.read(full_path) || ""
145
+ mime = path.end_with?(".rb") ? "text/x-ruby" : "text/html"
146
+ [ { uri: uri, mime_type: mime, text: view_content } ]
147
+ end
148
+
149
+ def resolve_routes(uri, controller)
150
+ context = RailsAiContext.introspect
151
+ routes_data = context[:routes] || {}
152
+
153
+ filtered = (routes_data[:routes] || []).select { |r|
154
+ r[:controller].to_s.include?(controller)
155
+ }
156
+ data = routes_data.merge(routes: filtered, filtered_by: controller)
157
+
158
+ [ { uri: uri, mime_type: "application/json", text: JSON.pretty_generate(data) } ]
159
+ end
160
+ end
161
+ end
162
+ end
@@ -3,7 +3,7 @@
3
3
  require "zeitwerk"
4
4
 
5
5
  loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
6
- loader.inflector.inflect("devops_introspector" => "DevOpsIntrospector", "cli" => "CLI")
6
+ loader.inflector.inflect("devops_introspector" => "DevOpsIntrospector", "cli" => "CLI", "vfs" => "VFS")
7
7
  loader.ignore("#{__dir__}/generators")
8
8
  loader.ignore("#{__dir__}/rails-ai-context.rb")
9
9
  loader.setup
data/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "5.2.0",
10
+ "version": "5.4.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
14
- "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v5.2.0/rails-ai-context-mcp.mcpb",
15
- "fileSha256": "PENDING_RELEASE_BUILD",
14
+ "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v5.4.0/rails-ai-context-mcp.mcpb",
15
+ "fileSha256": "0000000000000000000000000000000000000000000000000000000000000000",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
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.2.0
4
+ version: 5.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -207,6 +207,8 @@ files:
207
207
  - README.md
208
208
  - Rakefile
209
209
  - SECURITY.md
210
+ - app/controllers/rails_ai_context/mcp_controller.rb
211
+ - config/routes.rb
210
212
  - demo/demo-trace.gif
211
213
  - demo/demo-trace.tape
212
214
  - demo/demo.gif
@@ -224,6 +226,12 @@ files:
224
226
  - lib/rails_ai_context/doctor.rb
225
227
  - lib/rails_ai_context/engine.rb
226
228
  - lib/rails_ai_context/fingerprinter.rb
229
+ - lib/rails_ai_context/hydration_result.rb
230
+ - lib/rails_ai_context/hydrators/controller_hydrator.rb
231
+ - lib/rails_ai_context/hydrators/hydration_formatter.rb
232
+ - lib/rails_ai_context/hydrators/schema_hint_builder.rb
233
+ - lib/rails_ai_context/hydrators/view_hydrator.rb
234
+ - lib/rails_ai_context/instrumentation.rb
227
235
  - lib/rails_ai_context/introspector.rb
228
236
  - lib/rails_ai_context/introspectors/action_mailbox_introspector.rb
229
237
  - lib/rails_ai_context/introspectors/action_text_introspector.rb
@@ -248,6 +256,7 @@ files:
248
256
  - lib/rails_ai_context/introspectors/listeners/enums_listener.rb
249
257
  - lib/rails_ai_context/introspectors/listeners/macros_listener.rb
250
258
  - lib/rails_ai_context/introspectors/listeners/methods_listener.rb
259
+ - lib/rails_ai_context/introspectors/listeners/model_reference_listener.rb
251
260
  - lib/rails_ai_context/introspectors/listeners/scopes_listener.rb
252
261
  - lib/rails_ai_context/introspectors/listeners/validations_listener.rb
253
262
  - lib/rails_ai_context/introspectors/middleware_introspector.rb
@@ -270,6 +279,7 @@ files:
270
279
  - lib/rails_ai_context/middleware.rb
271
280
  - lib/rails_ai_context/resources.rb
272
281
  - lib/rails_ai_context/safe_file.rb
282
+ - lib/rails_ai_context/schema_hint.rb
273
283
  - lib/rails_ai_context/serializers/claude_rules_serializer.rb
274
284
  - lib/rails_ai_context/serializers/claude_serializer.rb
275
285
  - lib/rails_ai_context/serializers/compact_serializer_helper.rb
@@ -326,6 +336,7 @@ files:
326
336
  - lib/rails_ai_context/tools/session_context.rb
327
337
  - lib/rails_ai_context/tools/validate.rb
328
338
  - lib/rails_ai_context/version.rb
339
+ - lib/rails_ai_context/vfs.rb
329
340
  - lib/rails_ai_context/watcher.rb
330
341
  - server.json
331
342
  homepage: https://github.com/crisnahine/rails-ai-context