rails-ai-context 5.19.1 → 5.20.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 +128 -0
- data/CONTEXT.md +17 -1
- data/CONTRIBUTING.md +7 -5
- data/app/controllers/rails_ai_context/mcp_controller.rb +27 -30
- data/docs/COMPATIBILITY.md +1 -1
- data/docs/INTROSPECTORS.md +1 -1
- data/docs/SECURITY.md +37 -6
- data/docs/SETUP.md +1 -1
- data/docs/TROUBLESHOOTING.md +1 -1
- data/docs/adr/0001-own-listener-registration.md +9 -0
- data/docs/adr/0002-static-tier-declared-not-detected.md +5 -0
- data/docs/adr/0003-shared-tool-cache-semantics.md +23 -0
- data/docs/adr/0004-install-e2e-stays-example-rich.md +28 -0
- data/exe/rails-ai-context +55 -79
- data/gemfiles/prism_floor.gemfile +20 -0
- data/gemfiles/prism_head.gemfile +19 -0
- data/lib/generators/rails_ai_context/install/install_generator.rb +23 -63
- data/lib/rails_ai_context/cli.rb +12 -0
- data/lib/rails_ai_context/detail_level.rb +5 -0
- data/lib/rails_ai_context/doctor.rb +12 -14
- data/lib/rails_ai_context/hydrators/controller_hydrator.rb +1 -7
- data/lib/rails_ai_context/hydrators.rb +8 -0
- data/lib/rails_ai_context/install/ai_tool.rb +75 -0
- data/lib/rails_ai_context/install/cleanup.rb +42 -0
- data/lib/rails_ai_context/install/selection_record.rb +220 -0
- data/lib/rails_ai_context/install.rb +8 -0
- data/lib/rails_ai_context/introspector.rb +7 -5
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/config_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/credentials_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +4 -25
- data/lib/rails_ai_context/introspectors/env_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/job_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/listener_registration.rb +71 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +20 -3
- data/lib/rails_ai_context/introspectors/listeners.rb +10 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -4
- data/lib/rails_ai_context/introspectors/model_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/route_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/security_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/source_introspector.rb +7 -27
- data/lib/rails_ai_context/introspectors/static_tier.rb +67 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/test_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors.rb +8 -0
- data/lib/rails_ai_context/legacy_cleanup.rb +1 -6
- data/lib/rails_ai_context/mcp_config_generator.rb +1 -7
- data/lib/rails_ai_context/mcp_edge.rb +43 -0
- data/lib/rails_ai_context/middleware.rb +5 -22
- data/lib/rails_ai_context/redaction.rb +246 -0
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +9 -8
- data/lib/rails_ai_context/serializers/section_guard.rb +3 -4
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +13 -56
- data/lib/rails_ai_context/serializers.rb +8 -0
- data/lib/rails_ai_context/server.rb +4 -12
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +58 -142
- data/lib/rails_ai_context/tools/analyze_feature.rb +7 -2
- data/lib/rails_ai_context/tools/base_tool.rb +132 -7
- data/lib/rails_ai_context/tools/dependency_graph.rb +7 -2
- data/lib/rails_ai_context/tools/diagnose.rb +7 -0
- data/lib/rails_ai_context/tools/generate_test.rb +7 -0
- data/lib/rails_ai_context/tools/get_active_support.rb +18 -16
- data/lib/rails_ai_context/tools/get_api.rb +19 -23
- data/lib/rails_ai_context/tools/get_autoload.rb +30 -28
- data/lib/rails_ai_context/tools/get_callbacks.rb +22 -21
- data/lib/rails_ai_context/tools/get_component_catalog.rb +39 -39
- data/lib/rails_ai_context/tools/get_concern.rb +8 -1
- data/lib/rails_ai_context/tools/get_config.rb +65 -63
- data/lib/rails_ai_context/tools/get_context.rb +7 -1
- data/lib/rails_ai_context/tools/get_controllers.rb +116 -113
- data/lib/rails_ai_context/tools/get_conventions.rb +72 -70
- data/lib/rails_ai_context/tools/get_edit_context.rb +7 -0
- data/lib/rails_ai_context/tools/get_engines.rb +36 -34
- data/lib/rails_ai_context/tools/get_env.rb +17 -8
- data/lib/rails_ai_context/tools/get_env_config.rb +27 -24
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +19 -23
- data/lib/rails_ai_context/tools/get_gems.rb +31 -29
- data/lib/rails_ai_context/tools/get_helper_methods.rb +7 -3
- data/lib/rails_ai_context/tools/get_i18n.rb +17 -14
- data/lib/rails_ai_context/tools/get_job_pattern.rb +7 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +28 -25
- data/lib/rails_ai_context/tools/get_model_details.rb +66 -64
- data/lib/rails_ai_context/tools/get_partial_interface.rb +8 -3
- data/lib/rails_ai_context/tools/get_routes.rb +140 -139
- data/lib/rails_ai_context/tools/get_schema.rb +151 -149
- data/lib/rails_ai_context/tools/get_service_pattern.rb +7 -1
- data/lib/rails_ai_context/tools/get_stimulus.rb +110 -109
- data/lib/rails_ai_context/tools/get_test_info.rb +123 -122
- data/lib/rails_ai_context/tools/get_turbo_map.rb +7 -3
- data/lib/rails_ai_context/tools/get_view.rb +8 -3
- data/lib/rails_ai_context/tools/migration_advisor.rb +7 -0
- data/lib/rails_ai_context/tools/onboard.rb +7 -0
- data/lib/rails_ai_context/tools/performance_check.rb +75 -75
- data/lib/rails_ai_context/tools/query.rb +20 -4
- data/lib/rails_ai_context/tools/read_logs.rb +8 -66
- data/lib/rails_ai_context/tools/review_changes.rb +7 -0
- data/lib/rails_ai_context/tools/runtime_info.rb +8 -1
- data/lib/rails_ai_context/tools/safe_call.rb +40 -1
- data/lib/rails_ai_context/tools/search_code.rb +7 -0
- data/lib/rails_ai_context/tools/search_docs.rb +7 -0
- data/lib/rails_ai_context/tools/section_fetch.rb +81 -0
- data/lib/rails_ai_context/tools/security_scan.rb +7 -1
- data/lib/rails_ai_context/tools/session_context.rb +7 -0
- data/lib/rails_ai_context/tools/validate.rb +7 -0
- data/lib/rails_ai_context/tools.rb +8 -0
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +1 -1
- data/lib/rails_ai_context.rb +8 -0
- data/server.json +1 -1
- metadata +24 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e92439f72b8f723d75c6b62ca2fe3734f29b60548e50d2ddea836c93a66d952
|
|
4
|
+
data.tar.gz: c541d140c1f7971fa0f62d5e664673a338ffc7e809b8df8578035890224567d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2e550d201dd7af30d4e95e5ec368d2f376d2b8812d5254ffcf10ab8f78b1f0e089df5ba5f2664fbe858dbf2a5277c15e2489df0e86b61b22ebd44868560854f
|
|
7
|
+
data.tar.gz: 80daba010490cd5b6d1e0c4e770582b4bda8db4d073ebb9bead8d2eb3f015ec966c1ce1fef60f229755e1f1610b48b2287da480d790f20ed3be705d8b4ee95d7
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,134 @@ 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.20.0] - 2026-08-11
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- **Initializer config values are redacted where they are read, not where
|
|
13
|
+
they are rendered.** The config-assignment listener served raw source
|
|
14
|
+
slices, so a `config.secret_key = "..."` in `devise.rb` reached
|
|
15
|
+
`rails_get_context` and the generated context files in plaintext. Values
|
|
16
|
+
assigned to secret-named settings are now filtered at emission, which
|
|
17
|
+
covers every current and future reader of that listener.
|
|
18
|
+
- **Redaction and shortening are one operation.** They were separate calls
|
|
19
|
+
in the caller's hands, and getting the order wrong let a long credential
|
|
20
|
+
be cut apart before the pattern that would have caught it ever ran. The
|
|
21
|
+
module exposes `redact_and_shorten`, so the order is not a caller's to get
|
|
22
|
+
wrong.
|
|
23
|
+
- **The secret vocabulary widens, and nesting no longer hides a value.**
|
|
24
|
+
`pepper`, `salt`, `master_key`, `signing_key`, `encryption_key`,
|
|
25
|
+
`deterministic_key` and `encryption.primary_key` now read as secret names,
|
|
26
|
+
and a value under a secret-named setting is filtered however deeply it
|
|
27
|
+
nests. `config.secret = { primary: [ "..." ] }` emitted the credential in
|
|
28
|
+
full.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **The prism floor rises from 0.28 to 1.4.** The old range was a claim
|
|
33
|
+
nothing tested. A CI leg now pins prism at exactly 1.4.0 on Ruby 3.2, where
|
|
34
|
+
prism is a real gem rather than stdlib, with a trimmed gemfile so a dev
|
|
35
|
+
dependency cannot resolve it upward and turn the floor into a test of
|
|
36
|
+
whatever version won. A scheduled allowed-to-fail leg runs the suite
|
|
37
|
+
against prism's main branch, so a removal there surfaces here rather than
|
|
38
|
+
in your bundle. 0.28 was measured green in the one configuration that could
|
|
39
|
+
be checked locally; the raise is a tightening to a version CI proves, not a
|
|
40
|
+
fix for a known break.
|
|
41
|
+
- **Redaction markers converge on `[FILTERED]`.** One module now owns the
|
|
42
|
+
patterns and the vocabulary. `[EMAIL]` stays as the one semantic marker.
|
|
43
|
+
Marker presence and this vocabulary are contract going forward; see the
|
|
44
|
+
output contract in `docs/SECURITY.md`.
|
|
45
|
+
|
|
46
|
+
| Was | Now |
|
|
47
|
+
|-----|-----|
|
|
48
|
+
| `[REDACTED]` | `[FILTERED]` |
|
|
49
|
+
| `[redacted]` | `[FILTERED]` |
|
|
50
|
+
| `[ENV VAR REDACTED]` | `[FILTERED]` |
|
|
51
|
+
| `[dotenv] Set [ENV VARS REDACTED]` | `[dotenv] Set [FILTERED]` |
|
|
52
|
+
| `[EMAIL]` | `[EMAIL]` (unchanged) |
|
|
53
|
+
|
|
54
|
+
- **`rails_get_schema` says "failed:" where it used to say "not available:"**
|
|
55
|
+
when the introspector raised. It was the only one of the sixteen tools
|
|
56
|
+
carrying that preamble whose failed-case wording differed; it now shares
|
|
57
|
+
the phrasing the other fifteen use. The not-available and unavailable
|
|
58
|
+
answers are unchanged.
|
|
59
|
+
- **An invalid `detail` answers at the default level and says it did.**
|
|
60
|
+
Eleven tools each answered `Unknown detail level: x` and nothing else;
|
|
61
|
+
`detail` is normalized once now, before any tool runs, so junk and omission
|
|
62
|
+
both land on the default. You still get told: the response carries a note
|
|
63
|
+
naming the value that was discarded and the levels that exist, so a typo is
|
|
64
|
+
visible without eleven copies of the check. Tools that spell their own
|
|
65
|
+
levels (`rails_onboard`: quick/standard/full) are untouched.
|
|
66
|
+
- **Listener registration is derived from the listener.** Defining an `on_*`
|
|
67
|
+
handler is now its registration, validated against the events the running
|
|
68
|
+
prism dispatches, so a typo'd handler raises instead of never firing. The
|
|
69
|
+
hand-typed event allowlist that shipped the 5.19.1 fabricated-routes bug is
|
|
70
|
+
gone, along with the hydrator's private copy of it.
|
|
71
|
+
- **MCP view resources resolve through the current app root** rather than
|
|
72
|
+
`Rails.root`, so `rails-ai-context://views/...` works in the static tier.
|
|
73
|
+
- **One error frame for the MCP edge.** The middleware and the engine
|
|
74
|
+
controller each built their own JSON-RPC internal-error body; both now read
|
|
75
|
+
it from one place, so a failure looks the same whichever transport served
|
|
76
|
+
it. Transport construction moves behind one factory; memoization stays with
|
|
77
|
+
each caller, which holds a different scope (per instance, per class, per
|
|
78
|
+
process).
|
|
79
|
+
- **A setting's name decides whether it is redacted, not its value's type.**
|
|
80
|
+
`config.secret_key = 12345` was filtered in one emitted field and left plain
|
|
81
|
+
in the other, because one path saw an Integer and the other saw the source
|
|
82
|
+
text. One decision drives both now.
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- **Twenty-seven sections now answer with the app not booted.** Gems, i18n,
|
|
87
|
+
views, view templates, tests, jobs, turbo, stimulus, assets, devops, seeds,
|
|
88
|
+
middleware, engines, components, performance, credentials, env and the rest
|
|
89
|
+
read files the static tier already had on disk; they refused only because
|
|
90
|
+
nothing had said they could answer. Each introspector now declares its
|
|
91
|
+
static tier (files-only, alternate-source, or runtime-only), an undeclared
|
|
92
|
+
one fails the suite, and every files-only declaration is proven against a
|
|
93
|
+
fixture app with nothing booted. Runtime-only sections - config, api,
|
|
94
|
+
conventions, autoload, security, observability, database stats, connection
|
|
95
|
+
pool, initializers - still refuse honestly.
|
|
96
|
+
|
|
97
|
+
### Fixed
|
|
98
|
+
|
|
99
|
+
- **Re-running install through a different entry keeps your AI-tool
|
|
100
|
+
selection.** The Rails generator read it from the initializer, the
|
|
101
|
+
standalone CLI read it from `.rails-ai-context.yml`, and the rake task
|
|
102
|
+
kept a third copy of the logic. Switching between them silently dropped
|
|
103
|
+
the previous choice and re-prompted from scratch. All three read and write
|
|
104
|
+
the same record now, initializer first on read because that is the file you
|
|
105
|
+
hand-edit. Recording the selection also reaches both files together, so an
|
|
106
|
+
entry can no longer leave a stale initializer that outranks the YAML it
|
|
107
|
+
just wrote. An initializer with a `configure` block but no selection line
|
|
108
|
+
now gets one from any entry, not just the rake task.
|
|
109
|
+
- **The standalone HTTP server scopes its session record per client.** The
|
|
110
|
+
Rack middleware and the engine controller got this; `rails-ai-context serve
|
|
111
|
+
--transport http` serves many clients from one process too and was still
|
|
112
|
+
pooling their `rails_session_context` history.
|
|
113
|
+
- **The generated guide advertised a CLI command that does not exist.** Its
|
|
114
|
+
row for `rails_get_env_config` printed `ai:tool[environments]`, which the
|
|
115
|
+
CLI cannot resolve; the CLI name is now derived from the tool name rather
|
|
116
|
+
than typed beside it.
|
|
117
|
+
- **One MCP client's session record no longer shows up in another's.**
|
|
118
|
+
`rails_session_context` kept one process-global list of calls, which is
|
|
119
|
+
right over stdio but wrong for the two HTTP transports, where a single
|
|
120
|
+
process serves every client. The record is now bucketed by
|
|
121
|
+
`Mcp-Session-Id`. A caller's snapshot of the record also stopped changing
|
|
122
|
+
under it: `session_queries` returned live entries that later calls kept
|
|
123
|
+
mutating.
|
|
124
|
+
- **`.env.example` placeholders are shown, not filtered.** A
|
|
125
|
+
`<your-secret-here>` came back as `[FILTERED]`, hiding the one thing an
|
|
126
|
+
example file exists to show. A credential's shape and a placeholder's are
|
|
127
|
+
told apart now.
|
|
128
|
+
- **The HTTP session record evicts the least recently used client, and is
|
|
129
|
+
bounded.** It dropped the oldest-created session, which is usually the
|
|
130
|
+
busiest one still in use, and it grew for as long as the process lived.
|
|
131
|
+
- **Tools no longer report themselves as failing on mcp 0.8.** The note
|
|
132
|
+
naming a discarded `detail` value read `meta` off the response, which the
|
|
133
|
+
0.8 SDK has no reader for, so building the note raised and the tool
|
|
134
|
+
answered `Tool ... failed:`.
|
|
135
|
+
|
|
8
136
|
## [5.19.1] - 2026-08-10
|
|
9
137
|
|
|
10
138
|
### Fixed
|
data/CONTEXT.md
CHANGED
|
@@ -12,6 +12,22 @@ Overloaded. Always qualify it; never use "environment" bare in a tool name, an i
|
|
|
12
12
|
|
|
13
13
|
**Environment** (unqualified, as data) - a single named Rails environment: development, production, staging. This is the only sense in which the bare word is allowed, and only as a value, never as a name. The `:environments` payload key means "the list of these", which is why it kept its name when the introspector was renamed.
|
|
14
14
|
|
|
15
|
+
## AI tool
|
|
16
|
+
|
|
17
|
+
The assistant a user points at their app: claude, cursor, copilot, opencode, codex. What the gem generates for one is its **context files** (`CLAUDE.md`, `.cursor/rules/*.mdc`, and so on).
|
|
18
|
+
|
|
19
|
+
Avoid "format" for either sense - claude is not a file format, and the word collides with real formatting elsewhere in the code. The public config key `ai_tools` already uses the canonical term.
|
|
20
|
+
|
|
21
|
+
`Install::AiTool` is the one table of what each means: name, context files, MCP config shape, legacy leftovers. `Install::SelectionRecord` owns which ones the user picked - written to YAML always and to the initializer line inside a Rails app, read initializer-first because that is the file a user hand-edits.
|
|
22
|
+
|
|
15
23
|
## Static tier
|
|
16
24
|
|
|
17
|
-
The mode where the app did not boot, or `--no-boot` was passed.
|
|
25
|
+
The mode where the app did not boot, or `--no-boot` was passed. What an introspector answers here is what it declared, never what a runtime check happened to detect. Every introspector in `INTROSPECTOR_MAP` extends `StaticTier` and names one of three kinds:
|
|
26
|
+
|
|
27
|
+
**files-only** - `call` runs unchanged against the static app handle, because it only ever read files. Most of the map.
|
|
28
|
+
|
|
29
|
+
**alternate-source** - `static_call` answers from a different source than `call` does: `db/schema.rb` instead of the connection, `config/routes.rb` instead of the route set.
|
|
30
|
+
|
|
31
|
+
**runtime-only** - needs `app.config`, `app.routes` or a live database, and refuses honestly.
|
|
32
|
+
|
|
33
|
+
An undeclared introspector fails the suite, and every files-only declaration is proven against `spec/fixtures/static_app` with nothing booted. Declaring files-only while defining `static_call` (or the reverse) is also a spec failure.
|
data/CONTRIBUTING.md
CHANGED
|
@@ -33,16 +33,18 @@ lib/rails_ai_context/
|
|
|
33
33
|
1. Create `lib/rails_ai_context/introspectors/your_introspector.rb` (auto-loaded by Zeitwerk)
|
|
34
34
|
2. Implement `#initialize(app)` and `#call` → returns a Hash (never raises)
|
|
35
35
|
3. Register it in `lib/rails_ai_context/introspector.rb` (the `INTROSPECTOR_MAP`)
|
|
36
|
-
4.
|
|
37
|
-
5.
|
|
36
|
+
4. `extend StaticTier` and declare one: `static_tier :files_only` if `#call` runs unchanged with nothing booted, `:runtime_only` if it needs live reflection, or `:alternate_source` plus a `static_call` reading elsewhere. A mapped introspector that declares nothing fails the suite
|
|
37
|
+
5. Add the key to the appropriate preset(s) in `Configuration::PRESETS` (`:full` is the default, `:standard` for core-only)
|
|
38
|
+
6. Write specs in `spec/lib/rails_ai_context/your_introspector_spec.rb`
|
|
38
39
|
|
|
39
40
|
## Adding a New MCP Tool
|
|
40
41
|
|
|
41
42
|
1. Create `lib/rails_ai_context/tools/your_tool.rb` inheriting from `BaseTool` (auto-loaded by Zeitwerk)
|
|
42
43
|
2. Define `tool_name`, `description`, `input_schema`, and `annotations`
|
|
43
44
|
3. Implement `def self.call(...)` returning `text_response(string)`
|
|
44
|
-
4.
|
|
45
|
-
5.
|
|
45
|
+
4. Declare `guide_row(order:, mcp:, cli_args:, summary:)` - the generated guide is derived from it. A tool without one fails the inventory spec, and `order` must be unique and leave no gap in the sequence
|
|
46
|
+
5. Auto-registered - no manual list to update (BaseTool.inherited tracks it)
|
|
47
|
+
6. Write specs in `spec/lib/rails_ai_context/tools/your_tool_spec.rb`
|
|
46
48
|
|
|
47
49
|
## Adding a Prism Listener
|
|
48
50
|
|
|
@@ -52,7 +54,7 @@ Listeners extract specific concerns (associations, validations, etc.) from the A
|
|
|
52
54
|
2. Implement `on_call_node_enter(node)` and/or `on_def_node_enter(node)` - only the events your concern needs
|
|
53
55
|
3. Use `confidence_for(node)` from `BaseListener` to tag results `[VERIFIED]` or `[INFERRED]`
|
|
54
56
|
4. Store results in `@results` (accessed via `#results`) as plain hashes, never Prism nodes
|
|
55
|
-
5.
|
|
57
|
+
5. Nothing to register - `ListenerRegistration` derives the events from the `on_*` methods you defined, and raises if one names an event prism never dispatches
|
|
56
58
|
6. Register the key/class pair in `SourceIntrospector::LISTENER_MAP` only if the listener should run on every model walk; listeners used by one introspector are passed to `SourceIntrospector.walk(path, key => Listener)` at the call site instead
|
|
57
59
|
7. Write specs in `spec/lib/rails_ai_context/introspectors/listeners/your_listener_spec.rb`
|
|
58
60
|
8. Add a row to the listener catalogue in `docs/INTROSPECTORS.md`
|
|
@@ -27,6 +27,32 @@ module RailsAiContext
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def handle
|
|
30
|
+
Tools::BaseTool.with_session_for(request.env) do
|
|
31
|
+
serve_transport
|
|
32
|
+
end
|
|
33
|
+
rescue => e
|
|
34
|
+
# Once the response is committed the status and headers are already on
|
|
35
|
+
# the wire, so a JSON-RPC frame written here cannot reach the client.
|
|
36
|
+
# Worse, assigning a body swaps the stream out from under the thread
|
|
37
|
+
# still draining the old one, turning a truncated SSE stream into a
|
|
38
|
+
# garbled one. The streaming branch's ensure always closes the stream,
|
|
39
|
+
# and closing commits, so every streaming failure lands here committed.
|
|
40
|
+
# Hand those to Live, which logs them with a backtrace and tears the
|
|
41
|
+
# connection down.
|
|
42
|
+
raise if response.committed?
|
|
43
|
+
|
|
44
|
+
# A transport failure must still answer in JSON-RPC shape. Without this
|
|
45
|
+
# the exception escapes into a generic Rails 500 (HTML), breaking the
|
|
46
|
+
# client's JSON-RPC loop.
|
|
47
|
+
RailsAiContext.log_warn "[rails-ai-context] MCP request failed: #{e.class}: #{e.message}"
|
|
48
|
+
self.status = 500
|
|
49
|
+
response.headers["Content-Type"] = "application/json"
|
|
50
|
+
self.response_body = McpEdge.internal_error_frame(e)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def serve_transport
|
|
30
56
|
status_code, rack_headers, body = self.class.mcp_transport.handle_request(request)
|
|
31
57
|
self.status = status_code
|
|
32
58
|
apply_transport_headers(rack_headers)
|
|
@@ -67,32 +93,8 @@ module RailsAiContext
|
|
|
67
93
|
else
|
|
68
94
|
self.response_body = body
|
|
69
95
|
end
|
|
70
|
-
rescue => e
|
|
71
|
-
# Once the response is committed the status and headers are already on
|
|
72
|
-
# the wire, so a JSON-RPC frame written here cannot reach the client.
|
|
73
|
-
# Worse, assigning a body swaps the stream out from under the thread
|
|
74
|
-
# still draining the old one, turning a truncated SSE stream into a
|
|
75
|
-
# garbled one. The streaming branch's ensure always closes the stream,
|
|
76
|
-
# and closing commits, so every streaming failure lands here committed.
|
|
77
|
-
# Hand those to Live, which logs them with a backtrace and tears the
|
|
78
|
-
# connection down.
|
|
79
|
-
raise if response.committed?
|
|
80
|
-
|
|
81
|
-
# Mirror Middleware#json_rpc_error_response: a transport failure must
|
|
82
|
-
# still answer in JSON-RPC shape. Without this the exception escapes
|
|
83
|
-
# into a generic Rails 500 (HTML), breaking the client's JSON-RPC loop.
|
|
84
|
-
RailsAiContext.log_warn "[rails-ai-context] MCP request failed: #{e.class}: #{e.message}"
|
|
85
|
-
self.status = 500
|
|
86
|
-
response.headers["Content-Type"] = "application/json"
|
|
87
|
-
self.response_body = {
|
|
88
|
-
jsonrpc: "2.0",
|
|
89
|
-
error: { code: -32603, message: "Internal error: #{e.message}" },
|
|
90
|
-
id: nil
|
|
91
|
-
}.to_json
|
|
92
96
|
end
|
|
93
97
|
|
|
94
|
-
private
|
|
95
|
-
|
|
96
98
|
# Rack 3 transports name their headers in lowercase, and the MCP SDK
|
|
97
99
|
# switched to that in 1.0. Rails 7.0 keeps response headers in a
|
|
98
100
|
# case-sensitive Hash, so a lowercase "content-type" is invisible to the
|
|
@@ -131,12 +133,7 @@ module RailsAiContext
|
|
|
131
133
|
# Class-level memoization - transport persists across requests.
|
|
132
134
|
# Thread-safe: MCP::Server and transport are stateless for reads.
|
|
133
135
|
def mcp_transport
|
|
134
|
-
@transport_mutex.synchronize
|
|
135
|
-
@mcp_transport ||= begin
|
|
136
|
-
server = RailsAiContext::Server.new(Rails.application, transport: :http).build
|
|
137
|
-
MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
|
138
|
-
end
|
|
139
|
-
end
|
|
136
|
+
@transport_mutex.synchronize { @mcp_transport ||= McpEdge.build_transport }
|
|
140
137
|
end
|
|
141
138
|
|
|
142
139
|
def reset_transport!
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -19,7 +19,7 @@ document is aspirational.
|
|
|
19
19
|
- **Rails (railties):** 7.0 - 8.1 (gemspec: `railties >= 7.0, < 9.0`)
|
|
20
20
|
- **mcp gem:** `>= 0.8, < 2.0`
|
|
21
21
|
- **thor:** `>= 1.0, < 3.0`
|
|
22
|
-
- **prism:** `>=
|
|
22
|
+
- **prism:** `>= 1.4, < 2.0` (a CI leg pins the floor exactly and runs the suite against it)
|
|
23
23
|
- **concurrent-ruby:** `>= 1.2, < 3.0`
|
|
24
24
|
|
|
25
25
|
The gemspec's `railties` bound is wider than the CI matrix: point releases inside
|
data/docs/INTROSPECTORS.md
CHANGED
|
@@ -214,7 +214,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
|
|
|
214
214
|
|
|
215
215
|
1. Subclass `BaseListener` in `lib/rails_ai_context/introspectors/listeners/`. Use its helpers rather than re-reading nodes: `extract_symbol_args`, `extract_keyword_options`, `extract_arg_values` (source-slice fallback for expressions like `2.hours`), `extract_keyword_sources`, `extract_keyword_nodes`, `keyword_hash`, `constant_path_string`.
|
|
216
216
|
2. Implement the `on_*_node_enter` hooks you need and push plain hashes onto `@results`. Never return Prism nodes as the result itself; `option_nodes` is the one deliberate exception, for callers that must inspect an expression's shape.
|
|
217
|
-
3.
|
|
217
|
+
3. Nothing to register. `ListenerRegistration` reads the events off the `on_*` methods you defined, inherited ones included, and raises if one names an event prism never dispatches.
|
|
218
218
|
4. Add a spec of the same name under `spec/lib/rails_ai_context/introspectors/listeners/`.
|
|
219
219
|
5. Add a row to the table above.
|
|
220
220
|
|
data/docs/SECURITY.md
CHANGED
|
@@ -190,14 +190,45 @@ Complex patterns that would cause catastrophic backtracking raise `RegexpError`
|
|
|
190
190
|
|
|
191
191
|
---
|
|
192
192
|
|
|
193
|
-
##
|
|
193
|
+
## Redaction
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
Everything that leaves your app through this gem - log lines, query rows, environment values, config source slices - passes through one redaction module before anything else can touch it. Redacting and shortening are a single operation there, so a long credential cannot be cut apart in a way that hides it from the pattern that would have caught it.
|
|
196
196
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
-
|
|
200
|
-
-
|
|
197
|
+
What gets redacted:
|
|
198
|
+
|
|
199
|
+
- Passwords, tokens, secrets and API keys, wherever they are named
|
|
200
|
+
- Credentials embedded in URIs (`redis://user:pass@host`)
|
|
201
|
+
- Email addresses in log lines
|
|
202
|
+
- Values assigned to secret-named settings in initializers
|
|
203
|
+
|
|
204
|
+
### Markers
|
|
205
|
+
|
|
206
|
+
Redacted values carry one of two markers, and only these two:
|
|
207
|
+
|
|
208
|
+
| Marker | Means |
|
|
209
|
+
|--------|-------|
|
|
210
|
+
| `[FILTERED]` | A value was removed because it is or may be a secret |
|
|
211
|
+
| `[EMAIL]` | An email address was removed |
|
|
212
|
+
|
|
213
|
+
Marker presence and this vocabulary are part of the output contract (see below) - you can pattern-match on them.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Output contract
|
|
218
|
+
|
|
219
|
+
**Contract, safe to depend on:**
|
|
220
|
+
|
|
221
|
+
- Tool names and their input schemas
|
|
222
|
+
- The presence of a redaction marker wherever a value was removed
|
|
223
|
+
- The `[FILTERED]` / `[EMAIL]` marker vocabulary
|
|
224
|
+
|
|
225
|
+
**Incidental, expected to change between releases:**
|
|
226
|
+
|
|
227
|
+
- Response text, headings and formatting
|
|
228
|
+
- Refusal and unavailability wording
|
|
229
|
+
- Row and section ordering
|
|
230
|
+
|
|
231
|
+
Pin your assertions to the first list. When something in the second list changes in a way that could break a pinned assertion, the CHANGELOG says so.
|
|
201
232
|
|
|
202
233
|
---
|
|
203
234
|
|
data/docs/SETUP.md
CHANGED
|
@@ -298,7 +298,7 @@ Type in Claude Code's prompt:
|
|
|
298
298
|
What MCP tools do you have access to?
|
|
299
299
|
```
|
|
300
300
|
|
|
301
|
-
You should see `rails_get_schema`, `rails_search_code`, and the
|
|
301
|
+
You should see `rails_get_schema`, `rails_search_code`, and the rest of the tools listed.
|
|
302
302
|
|
|
303
303
|
### Cursor
|
|
304
304
|
|
data/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -188,7 +188,7 @@ Common false positives:
|
|
|
188
188
|
- Hash characters in strings → write the hash outside a comment position
|
|
189
189
|
- JSONB operators (`#>>`) → preserved correctly since v5.6.0
|
|
190
190
|
|
|
191
|
-
### "Column values show [
|
|
191
|
+
### "Column values show [FILTERED]"
|
|
192
192
|
|
|
193
193
|
Columns matching sensitive patterns are redacted. Configure:
|
|
194
194
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Derive Prism listener registration ourselves, not via prism's register_public_methods
|
|
2
|
+
|
|
3
|
+
Status: accepted
|
|
4
|
+
|
|
5
|
+
Prism 1.9+ ships `Dispatcher#register_public_methods`, which looks like the obvious replacement for the hand-typed event allowlist that shipped the fabricated-static-routes bug (c8caa40). We derive registration in our own module instead, for two reasons that are easy to rediscover the hard way: the gem's prism floor is below 1.9, where the helper does not exist, and the helper is built on `public_methods(false)`, which skips inherited handlers - `VariantCallListener` inherits its only handler from `ChainedCallListener` and would register zero events, silently.
|
|
6
|
+
|
|
7
|
+
(The floor was 0.28 when this was written and is 1.4 now; the first reason holds either way. See the gemspec for the current range, and the `prism floor` CI leg that tests it.)
|
|
8
|
+
|
|
9
|
+
Our module enumerates `on_*` handlers inheritance-aware and validates every name against the running prism's real event set, raising on unknowns, so an unregistered or typo'd handler fails loud instead of never firing. Do not swap the prism helper back in as a simplification; it reintroduces both silent failure modes.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Static tier capability is declared per introspector, never auto-detected
|
|
2
|
+
|
|
3
|
+
Status: accepted
|
|
4
|
+
|
|
5
|
+
Every mapped introspector must declare how it answers in the static tier: its `call` already works from files alone, or it refuses honestly, or it defines `static_call` against an alternate static source. We rejected auto-detecting "file-only" introspectors because a default-open guess re-creates the failure mode the tier exists to prevent: an introspector that grows a runtime dependency while still looking file-only would silently serve wrong data (the c8caa40 class). Declaration fails closed - a mapped introspector that declares nothing is a spec failure, and a shared fixture spec proves every files-only declaration true under `StaticApp`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# The two shared tool caches: what is a defect and what is intended
|
|
2
|
+
|
|
3
|
+
Status: accepted
|
|
4
|
+
|
|
5
|
+
`Tools::BaseTool` keeps two process-global structures - `SHARED_CACHE` (one introspection result for every tool) and `SESSION_CONTEXT` (what has been called with what). Both are read by four entry points - stdio, the Rack middleware, the engine controller and the standalone HTTP server - three of which serve many clients from one process. This records what the audit found, so the next reader does not re-derive it from the mutex.
|
|
6
|
+
|
|
7
|
+
## Defects, fixed
|
|
8
|
+
|
|
9
|
+
**One client's session record was served to another.** `SESSION_CONTEXT[:queries]` was a single flat hash. Over stdio that is right - one process is one conversation - but the middleware, the engine controller and the standalone HTTP server each serve every client from one process, so `rails_session_context` listed calls the asking client never made. The record is now bucketed per conversation: all three HTTP entry points wrap each request in `BaseTool.with_session(<Mcp-Session-Id>)`, and stdio falls through to a single default bucket, unchanged.
|
|
10
|
+
|
|
11
|
+
**`session_queries` handed out live entries.** It returned `values.dup`, a shallow copy, so the entry hashes stayed live inside the record and kept being mutated by later calls - a caller's snapshot changed under it. It now copies each entry.
|
|
12
|
+
|
|
13
|
+
**The record grew without bound, keyed by a client-controlled header.** Bucketing per conversation introduced a hash whose keys come from `Mcp-Session-Id`, in a process that stays up. Three things were wrong at once: the hash had a default block, so merely *reading* a session's history created a bucket; nothing capped the id's length; and nothing evicted. Reading no longer writes, ids are truncated to `MAX_SESSION_ID_LENGTH`, and the number of remembered conversations is capped at `MAX_SESSIONS`.
|
|
14
|
+
|
|
15
|
+
Eviction is least-recently-used, not oldest-created. Recording re-inserts the session so hash order tracks use. Ordering by creation instead would evict the conversation that has run for hours ahead of a hundred idle newcomers - backwards, and worst on exactly the long-lived transports that made bucketing necessary.
|
|
16
|
+
|
|
17
|
+
## Intended semantics, not defects
|
|
18
|
+
|
|
19
|
+
**The fingerprint walk happens under the mutex, not outside it.** The whole read path in `cached_context` - TTL check, fingerprint comparison, re-introspection - is inside one `synchronize`, so concurrent callers serialize instead of racing to introspect. Twenty threads arriving together produce exactly one introspection. The cost is that a fingerprint walk (~12ms in dev-mode installs, ~0.5ms in production) blocks other tool calls for its duration. That is the trade we want: a lock-free fast path would let several threads run the full 40-introspector walk at once, which is far more expensive than the wait it avoids.
|
|
20
|
+
|
|
21
|
+
**`deep_dup` also happens under the mutex.** Every caller gets an independent copy, so a tool that mutates its context cannot corrupt the next tool's view. Copying inside the lock adds to hold time, and moving it outside would hand out the cached object itself between the read and the copy. Isolation is worth more than the contention; the copy is cheap next to the introspection it protects.
|
|
22
|
+
|
|
23
|
+
Both are pinned by `spec/lib/rails_ai_context/tools/shared_cache_concurrency_spec.rb`, which also drives the middleware and the engine controller concurrently against both caches.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# The install e2e specs keep their examples; the fast specs took the load instead
|
|
2
|
+
|
|
3
|
+
Status: accepted
|
|
4
|
+
|
|
5
|
+
The install program (#116, #117) planned to shrink e2e install coverage to "one smoke per entry", on the stated grounds that install changes were paying a 30-minute loop. Measured before acting, that premise does not hold, so the trim was not made.
|
|
6
|
+
|
|
7
|
+
## What it costs now
|
|
8
|
+
|
|
9
|
+
The whole e2e suite is about 4 minutes for 159 examples, and builds 11 throwaway Rails apps. The three install specs account for 3 of those apps and 31 examples in roughly 73 seconds.
|
|
10
|
+
|
|
11
|
+
Within one install spec, the app is the cost and the examples are nearly free:
|
|
12
|
+
|
|
13
|
+
| Run | Time |
|
|
14
|
+
|-----|------|
|
|
15
|
+
| `standalone_install_spec.rb`, all 7 examples | 9.6s |
|
|
16
|
+
| the same file, 1 example | 6.5s |
|
|
17
|
+
|
|
18
|
+
So `rails new` plus `bundle install` is ~6.2s and each further example is ~0.5s. Cutting all three files to one example each would drop ~28 examples and save ~14 seconds of a 4-minute suite. The apps - the actual cost - would all still be built, because the three install paths (in-Gemfile, standalone, zero-config) are what the separate apps exist to distinguish.
|
|
19
|
+
|
|
20
|
+
## What it would cost to trim
|
|
21
|
+
|
|
22
|
+
Three of the surviving install examples have no unit-level equivalent, because they exercise the generator's prompting rather than its output: stdin hitting EOF mid-prompt, `--defaults` skipping every prompt, and a re-run being idempotent. Those are the paths that broke before, and they cannot be reproduced without running the real generator against a real app.
|
|
23
|
+
|
|
24
|
+
## The part of the goal that was real
|
|
25
|
+
|
|
26
|
+
The intent behind the bullet - that install changes stop paying a slow loop - is served by moving the questions that used to need an app into fast specs. `Install::SelectionRecord` and `Install::AiTool` are unit-tested with no Rails boot, and the entry round-trip that used to justify three e2e runs is now `spec/lib/rails_ai_context/install/entry_parity_spec.rb`, which runs in milliseconds.
|
|
27
|
+
|
|
28
|
+
Revisit this if the app-build count grows or if a fixture app can be built once and shared across install paths. Deleting examples is not the lever.
|