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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +55 -9
- data/app/controllers/rails_ai_context/mcp_controller.rb +43 -0
- data/config/routes.rb +5 -0
- data/docs/GUIDE.md +27 -3
- data/lib/rails_ai_context/configuration.rb +7 -0
- data/lib/rails_ai_context/hydration_result.rb +14 -0
- data/lib/rails_ai_context/hydrators/controller_hydrator.rb +51 -0
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +56 -0
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +73 -0
- data/lib/rails_ai_context/hydrators/view_hydrator.rb +56 -0
- data/lib/rails_ai_context/instrumentation.rb +22 -0
- data/lib/rails_ai_context/introspectors/listeners/model_reference_listener.rb +106 -0
- data/lib/rails_ai_context/live_reload.rb +1 -1
- data/lib/rails_ai_context/resources.rb +34 -1
- data/lib/rails_ai_context/schema_hint.rb +28 -0
- data/lib/rails_ai_context/server.rb +7 -1
- data/lib/rails_ai_context/tools/base_tool.rb +26 -0
- data/lib/rails_ai_context/tools/get_context.rb +11 -0
- data/lib/rails_ai_context/tools/get_controllers.rb +15 -0
- data/lib/rails_ai_context/tools/get_view.rb +14 -0
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +162 -0
- data/lib/rails_ai_context.rb +1 -1
- data/server.json +3 -3
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43e8a71c793404bd950627b8f3319b4e909a0ba7a2cfe937a8d1027e2ede4af0
|
|
4
|
+
data.tar.gz: f296960edc1f3f336e90b9549618fd4b48d51d31330d95a65de9304ba47aa0af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://rubygems.org/gems/rails-ai-context)
|
|
18
18
|
[](https://github.com/crisnahine/rails-ai-context/actions)
|
|
19
19
|
[](https://registry.modelcontextprotocol.io)
|
|
20
|
+
<br>
|
|
20
21
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
21
22
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
22
|
-
[](https://github.com/crisnahine/rails-ai-context/actions)
|
|
23
24
|
[](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
|
-
##
|
|
126
|
+
## Three ways to use it
|
|
126
127
|
|
|
127
128
|
<table>
|
|
128
129
|
<tr>
|
|
129
|
-
<td width="
|
|
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="
|
|
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/ │ │
|
|
386
|
-
│ .github/instr... │ │
|
|
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 **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
|
-
|
|
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
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
|
|
@@ -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"
|
|
@@ -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
|
data/lib/rails_ai_context.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
15
|
-
"fileSha256": "
|
|
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.
|
|
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
|