rails-ai-context 5.19.1 → 5.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +164 -0
  3. data/CONTEXT.md +17 -1
  4. data/CONTRIBUTING.md +7 -5
  5. data/app/controllers/rails_ai_context/mcp_controller.rb +27 -30
  6. data/docs/COMPATIBILITY.md +1 -1
  7. data/docs/INTROSPECTORS.md +1 -1
  8. data/docs/SECURITY.md +37 -6
  9. data/docs/SETUP.md +1 -1
  10. data/docs/TROUBLESHOOTING.md +1 -1
  11. data/docs/adr/0001-own-listener-registration.md +9 -0
  12. data/docs/adr/0002-static-tier-declared-not-detected.md +5 -0
  13. data/docs/adr/0003-shared-tool-cache-semantics.md +23 -0
  14. data/docs/adr/0004-install-e2e-stays-example-rich.md +28 -0
  15. data/exe/rails-ai-context +55 -79
  16. data/gemfiles/prism_floor.gemfile +20 -0
  17. data/gemfiles/prism_head.gemfile +19 -0
  18. data/lib/generators/rails_ai_context/install/install_generator.rb +23 -63
  19. data/lib/rails_ai_context/cli/tool_runner.rb +20 -3
  20. data/lib/rails_ai_context/cli.rb +12 -0
  21. data/lib/rails_ai_context/detail_level.rb +5 -0
  22. data/lib/rails_ai_context/doctor.rb +12 -14
  23. data/lib/rails_ai_context/hydrators/controller_hydrator.rb +1 -7
  24. data/lib/rails_ai_context/hydrators.rb +8 -0
  25. data/lib/rails_ai_context/install/ai_tool.rb +75 -0
  26. data/lib/rails_ai_context/install/cleanup.rb +42 -0
  27. data/lib/rails_ai_context/install/selection_record.rb +220 -0
  28. data/lib/rails_ai_context/install.rb +8 -0
  29. data/lib/rails_ai_context/introspector.rb +8 -8
  30. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +3 -0
  31. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +3 -0
  32. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +3 -0
  33. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +22 -0
  34. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -0
  35. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +3 -0
  36. data/lib/rails_ai_context/introspectors/auth_introspector.rb +3 -0
  37. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +3 -0
  38. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -0
  39. data/lib/rails_ai_context/introspectors/config_introspector.rb +3 -0
  40. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +3 -0
  41. data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -0
  42. data/lib/rails_ai_context/introspectors/convention_introspector.rb +3 -0
  43. data/lib/rails_ai_context/introspectors/credentials_introspector.rb +3 -0
  44. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +3 -0
  45. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -0
  46. data/lib/rails_ai_context/introspectors/engine_introspector.rb +17 -0
  47. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +4 -25
  48. data/lib/rails_ai_context/introspectors/env_introspector.rb +3 -0
  49. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +3 -0
  50. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -0
  51. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +69 -5
  52. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +3 -0
  53. data/lib/rails_ai_context/introspectors/job_introspector.rb +73 -0
  54. data/lib/rails_ai_context/introspectors/listener_registration.rb +71 -0
  55. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +20 -3
  56. data/lib/rails_ai_context/introspectors/listeners.rb +10 -0
  57. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +3 -0
  58. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -4
  59. data/lib/rails_ai_context/introspectors/model_introspector.rb +3 -0
  60. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +3 -0
  61. data/lib/rails_ai_context/introspectors/observability_introspector.rb +3 -0
  62. data/lib/rails_ai_context/introspectors/performance_introspector.rb +3 -0
  63. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +3 -0
  64. data/lib/rails_ai_context/introspectors/route_introspector.rb +8 -1
  65. data/lib/rails_ai_context/introspectors/schema_introspector.rb +3 -0
  66. data/lib/rails_ai_context/introspectors/security_introspector.rb +3 -0
  67. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +3 -0
  68. data/lib/rails_ai_context/introspectors/source_introspector.rb +7 -27
  69. data/lib/rails_ai_context/introspectors/static_tier.rb +76 -0
  70. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +3 -0
  71. data/lib/rails_ai_context/introspectors/test_introspector.rb +3 -0
  72. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +3 -0
  73. data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -0
  74. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +3 -0
  75. data/lib/rails_ai_context/introspectors.rb +8 -0
  76. data/lib/rails_ai_context/legacy_cleanup.rb +1 -6
  77. data/lib/rails_ai_context/mcp_config_generator.rb +1 -7
  78. data/lib/rails_ai_context/mcp_edge.rb +43 -0
  79. data/lib/rails_ai_context/middleware.rb +5 -22
  80. data/lib/rails_ai_context/redaction.rb +246 -0
  81. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +6 -5
  82. data/lib/rails_ai_context/serializers/context_file_serializer.rb +9 -8
  83. data/lib/rails_ai_context/serializers/copilot_serializer.rb +4 -2
  84. data/lib/rails_ai_context/serializers/section_guard.rb +3 -4
  85. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +13 -56
  86. data/lib/rails_ai_context/serializers.rb +8 -0
  87. data/lib/rails_ai_context/server.rb +49 -17
  88. data/lib/rails_ai_context/tasks/rails_ai_context.rake +58 -142
  89. data/lib/rails_ai_context/tools/analyze_feature.rb +7 -2
  90. data/lib/rails_ai_context/tools/base_tool.rb +132 -7
  91. data/lib/rails_ai_context/tools/dependency_graph.rb +7 -2
  92. data/lib/rails_ai_context/tools/diagnose.rb +7 -0
  93. data/lib/rails_ai_context/tools/generate_test.rb +7 -0
  94. data/lib/rails_ai_context/tools/get_active_support.rb +34 -16
  95. data/lib/rails_ai_context/tools/get_api.rb +19 -23
  96. data/lib/rails_ai_context/tools/get_autoload.rb +30 -28
  97. data/lib/rails_ai_context/tools/get_callbacks.rb +22 -21
  98. data/lib/rails_ai_context/tools/get_component_catalog.rb +39 -39
  99. data/lib/rails_ai_context/tools/get_concern.rb +8 -1
  100. data/lib/rails_ai_context/tools/get_config.rb +65 -63
  101. data/lib/rails_ai_context/tools/get_context.rb +7 -1
  102. data/lib/rails_ai_context/tools/get_controllers.rb +116 -113
  103. data/lib/rails_ai_context/tools/get_conventions.rb +72 -70
  104. data/lib/rails_ai_context/tools/get_edit_context.rb +7 -0
  105. data/lib/rails_ai_context/tools/get_engines.rb +38 -34
  106. data/lib/rails_ai_context/tools/get_env.rb +37 -8
  107. data/lib/rails_ai_context/tools/get_env_config.rb +27 -24
  108. data/lib/rails_ai_context/tools/get_frontend_stack.rb +19 -23
  109. data/lib/rails_ai_context/tools/get_gems.rb +34 -29
  110. data/lib/rails_ai_context/tools/get_helper_methods.rb +7 -3
  111. data/lib/rails_ai_context/tools/get_i18n.rb +20 -15
  112. data/lib/rails_ai_context/tools/get_job_pattern.rb +7 -1
  113. data/lib/rails_ai_context/tools/get_mailers.rb +29 -25
  114. data/lib/rails_ai_context/tools/get_model_details.rb +66 -64
  115. data/lib/rails_ai_context/tools/get_partial_interface.rb +8 -3
  116. data/lib/rails_ai_context/tools/get_routes.rb +149 -139
  117. data/lib/rails_ai_context/tools/get_schema.rb +151 -149
  118. data/lib/rails_ai_context/tools/get_service_pattern.rb +7 -1
  119. data/lib/rails_ai_context/tools/get_stimulus.rb +110 -109
  120. data/lib/rails_ai_context/tools/get_test_info.rb +123 -122
  121. data/lib/rails_ai_context/tools/get_turbo_map.rb +7 -3
  122. data/lib/rails_ai_context/tools/get_view.rb +8 -3
  123. data/lib/rails_ai_context/tools/migration_advisor.rb +7 -0
  124. data/lib/rails_ai_context/tools/onboard.rb +7 -0
  125. data/lib/rails_ai_context/tools/performance_check.rb +75 -75
  126. data/lib/rails_ai_context/tools/query.rb +20 -4
  127. data/lib/rails_ai_context/tools/read_logs.rb +8 -66
  128. data/lib/rails_ai_context/tools/review_changes.rb +7 -0
  129. data/lib/rails_ai_context/tools/runtime_info.rb +8 -1
  130. data/lib/rails_ai_context/tools/safe_call.rb +40 -1
  131. data/lib/rails_ai_context/tools/search_code.rb +7 -0
  132. data/lib/rails_ai_context/tools/search_docs.rb +7 -0
  133. data/lib/rails_ai_context/tools/section_fetch.rb +79 -0
  134. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  135. data/lib/rails_ai_context/tools/session_context.rb +7 -0
  136. data/lib/rails_ai_context/tools/validate.rb +7 -0
  137. data/lib/rails_ai_context/tools.rb +8 -0
  138. data/lib/rails_ai_context/version.rb +1 -1
  139. data/lib/rails_ai_context/vfs.rb +1 -1
  140. data/lib/rails_ai_context.rb +8 -0
  141. data/server.json +1 -1
  142. metadata +24 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 175ccbf5bac4703a8f9699708a02435a9b80546c09129deae79ffb5f182e14e6
4
- data.tar.gz: 0adbdf439e6ac853606fc786d6f92041a70aec4e2379ebfe98132eb14352b219
3
+ metadata.gz: e7cbf76ef448db45165cf06f3c2af8303b23f8629a709b20ffb61ae33093c192
4
+ data.tar.gz: 65fe97840d3fd23ab66b1b8e6b108914b3675e3648c51d746769d4016b6a846a
5
5
  SHA512:
6
- metadata.gz: 9bb08e8b5d06124360be6870c7f667f008a666d07bea55a33b89662049ea1ee00b3ab88b5c5060ee70c47b8d1b77c422cfd8434f3327094427e2ac321c8a9201
7
- data.tar.gz: 160c8b647c1a6be4bec7290be0308a98318edd92c2525235410bd1fed94459ddd107538f5ced4c8fced29745a9de00aa8dfc37f0bdc9274afa358c0aa2f82e3f
6
+ metadata.gz: b4816a51648a6aaee1a3fd48b649ff35967e2efea2c75805da08c605a0668b8afa7dbe113b8d7f555a13c268d72f1284b69091a3b9d102465e3a6b00f5b2282b
7
+ data.tar.gz: cc12eaa6400a9a2906ca706b37fc92f211f0837e69038f14e6a25742be8207d529c915c4ecdb122c6bd2a20a9dc0eb86a3474cd4a65a321fa8ded9f2aa64a47d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,170 @@ 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.1] - 2026-08-11
9
+
10
+ ### Fixed
11
+
12
+ - **`config.custom_tools` no longer takes the MCP server down.** Naming a
13
+ `BaseTool` subclass resolved its constant, which autoloaded the class and
14
+ enrolled it in the same registry the built-in list is read from, so it was
15
+ offered to the SDK twice and rejected as a duplicate name. The server exited
16
+ 1 with an empty stdout while the CLI kept working. Tools are now merged by
17
+ name; two different classes claiming one name keep the built-in and warn.
18
+ - **`--flag value` no longer inverts boolean parameters.** A boolean flag
19
+ consumed no value, so `--app-only false` set `app_only` to true and dropped
20
+ the `false` without a warning. Affected every boolean on every tool, and
21
+ `--param value` is the form the CLI docs teach.
22
+ - **The static tier stops answering questions it cannot answer.** `mailers`
23
+ reported "no mailers found" for an app with mailers, `engines` reported no
24
+ engines loaded, `i18n` reported one locale while listing two locale files,
25
+ and the Action Cable channels, deprecators, on_load hooks, cache store and
26
+ credentials sections vanished with no marker. Mailers, channels and locales
27
+ are read from source through `static_call`; the rest report
28
+ `[UNAVAILABLE]`. Locale files using YAML anchors are read correctly, and an
29
+ `I18n.default_locale` set in an initializer is honoured.
30
+ - **`app_only:false` lists the routes it counts.** It announced the unfiltered
31
+ total above a body containing only app routes, and never showed a framework
32
+ route. App routes are now listed first, so they cannot paginate out of sight.
33
+ `app_only:true` says how many routes it hid and how to see them.
34
+ - **One app, one route count.** Generated context files counted raw routes
35
+ while the tools merged each resource's `PATCH`/`PUT` pair, so `CLAUDE.md` and
36
+ `rails_get_routes` quoted different numbers for the same app. The totals are
37
+ merged at the source, and the serializers read
38
+ `config.excluded_route_prefixes` instead of a hardcoded copy.
39
+ - **`rails_get_gems` past the last page** no longer opens with "No notable gems
40
+ found" above its own "No items at offset N" note.
41
+ - The MCP startup banner reads the list off the built server, so it cannot
42
+ announce a different set of tools than the server answers with.
43
+
44
+ ## [5.20.0] - 2026-08-11
45
+
46
+ ### Security
47
+
48
+ - **Initializer config values are redacted where they are read, not where
49
+ they are rendered.** The config-assignment listener served raw source
50
+ slices, so a `config.secret_key = "..."` in `devise.rb` reached
51
+ `rails_get_context` and the generated context files in plaintext. Values
52
+ assigned to secret-named settings are now filtered at emission, which
53
+ covers every current and future reader of that listener.
54
+ - **Redaction and shortening are one operation.** They were separate calls
55
+ in the caller's hands, and getting the order wrong let a long credential
56
+ be cut apart before the pattern that would have caught it ever ran. The
57
+ module exposes `redact_and_shorten`, so the order is not a caller's to get
58
+ wrong.
59
+ - **The secret vocabulary widens, and nesting no longer hides a value.**
60
+ `pepper`, `salt`, `master_key`, `signing_key`, `encryption_key`,
61
+ `deterministic_key` and `encryption.primary_key` now read as secret names,
62
+ and a value under a secret-named setting is filtered however deeply it
63
+ nests. `config.secret = { primary: [ "..." ] }` emitted the credential in
64
+ full.
65
+
66
+ ### Changed
67
+
68
+ - **The prism floor rises from 0.28 to 1.4.** The old range was a claim
69
+ nothing tested. A CI leg now pins prism at exactly 1.4.0 on Ruby 3.2, where
70
+ prism is a real gem rather than stdlib, with a trimmed gemfile so a dev
71
+ dependency cannot resolve it upward and turn the floor into a test of
72
+ whatever version won. A scheduled allowed-to-fail leg runs the suite
73
+ against prism's main branch, so a removal there surfaces here rather than
74
+ in your bundle. 0.28 was measured green in the one configuration that could
75
+ be checked locally; the raise is a tightening to a version CI proves, not a
76
+ fix for a known break.
77
+ - **Redaction markers converge on `[FILTERED]`.** One module now owns the
78
+ patterns and the vocabulary. `[EMAIL]` stays as the one semantic marker.
79
+ Marker presence and this vocabulary are contract going forward; see the
80
+ output contract in `docs/SECURITY.md`.
81
+
82
+ | Was | Now |
83
+ |-----|-----|
84
+ | `[REDACTED]` | `[FILTERED]` |
85
+ | `[redacted]` | `[FILTERED]` |
86
+ | `[ENV VAR REDACTED]` | `[FILTERED]` |
87
+ | `[dotenv] Set [ENV VARS REDACTED]` | `[dotenv] Set [FILTERED]` |
88
+ | `[EMAIL]` | `[EMAIL]` (unchanged) |
89
+
90
+ - **`rails_get_schema` says "failed:" where it used to say "not available:"**
91
+ when the introspector raised. It was the only one of the sixteen tools
92
+ carrying that preamble whose failed-case wording differed; it now shares
93
+ the phrasing the other fifteen use. The not-available and unavailable
94
+ answers are unchanged.
95
+ - **An invalid `detail` answers at the default level and says it did.**
96
+ Eleven tools each answered `Unknown detail level: x` and nothing else;
97
+ `detail` is normalized once now, before any tool runs, so junk and omission
98
+ both land on the default. You still get told: the response carries a note
99
+ naming the value that was discarded and the levels that exist, so a typo is
100
+ visible without eleven copies of the check. Tools that spell their own
101
+ levels (`rails_onboard`: quick/standard/full) are untouched.
102
+ - **Listener registration is derived from the listener.** Defining an `on_*`
103
+ handler is now its registration, validated against the events the running
104
+ prism dispatches, so a typo'd handler raises instead of never firing. The
105
+ hand-typed event allowlist that shipped the 5.19.1 fabricated-routes bug is
106
+ gone, along with the hydrator's private copy of it.
107
+ - **MCP view resources resolve through the current app root** rather than
108
+ `Rails.root`, so `rails-ai-context://views/...` works in the static tier.
109
+ - **One error frame for the MCP edge.** The middleware and the engine
110
+ controller each built their own JSON-RPC internal-error body; both now read
111
+ it from one place, so a failure looks the same whichever transport served
112
+ it. Transport construction moves behind one factory; memoization stays with
113
+ each caller, which holds a different scope (per instance, per class, per
114
+ process).
115
+ - **A setting's name decides whether it is redacted, not its value's type.**
116
+ `config.secret_key = 12345` was filtered in one emitted field and left plain
117
+ in the other, because one path saw an Integer and the other saw the source
118
+ text. One decision drives both now.
119
+
120
+ ### Added
121
+
122
+ - **Twenty-seven sections now answer with the app not booted.** Gems, i18n,
123
+ views, view templates, tests, jobs, turbo, stimulus, assets, devops, seeds,
124
+ middleware, engines, components, performance, credentials, env and the rest
125
+ read files the static tier already had on disk; they refused only because
126
+ nothing had said they could answer. Each introspector now declares its
127
+ static tier (files-only, alternate-source, or runtime-only), an undeclared
128
+ one fails the suite, and every files-only declaration is proven against a
129
+ fixture app with nothing booted. Runtime-only sections - config, api,
130
+ conventions, autoload, security, observability, database stats, connection
131
+ pool, initializers - still refuse honestly.
132
+
133
+ ### Fixed
134
+
135
+ - **Re-running install through a different entry keeps your AI-tool
136
+ selection.** The Rails generator read it from the initializer, the
137
+ standalone CLI read it from `.rails-ai-context.yml`, and the rake task
138
+ kept a third copy of the logic. Switching between them silently dropped
139
+ the previous choice and re-prompted from scratch. All three read and write
140
+ the same record now, initializer first on read because that is the file you
141
+ hand-edit. Recording the selection also reaches both files together, so an
142
+ entry can no longer leave a stale initializer that outranks the YAML it
143
+ just wrote. An initializer with a `configure` block but no selection line
144
+ now gets one from any entry, not just the rake task.
145
+ - **The standalone HTTP server scopes its session record per client.** The
146
+ Rack middleware and the engine controller got this; `rails-ai-context serve
147
+ --transport http` serves many clients from one process too and was still
148
+ pooling their `rails_session_context` history.
149
+ - **The generated guide advertised a CLI command that does not exist.** Its
150
+ row for `rails_get_env_config` printed `ai:tool[environments]`, which the
151
+ CLI cannot resolve; the CLI name is now derived from the tool name rather
152
+ than typed beside it.
153
+ - **One MCP client's session record no longer shows up in another's.**
154
+ `rails_session_context` kept one process-global list of calls, which is
155
+ right over stdio but wrong for the two HTTP transports, where a single
156
+ process serves every client. The record is now bucketed by
157
+ `Mcp-Session-Id`. A caller's snapshot of the record also stopped changing
158
+ under it: `session_queries` returned live entries that later calls kept
159
+ mutating.
160
+ - **`.env.example` placeholders are shown, not filtered.** A
161
+ `<your-secret-here>` came back as `[FILTERED]`, hiding the one thing an
162
+ example file exists to show. A credential's shape and a placeholder's are
163
+ told apart now.
164
+ - **The HTTP session record evicts the least recently used client, and is
165
+ bounded.** It dropped the oldest-created session, which is usually the
166
+ busiest one still in use, and it grew for as long as the process lived.
167
+ - **Tools no longer report themselves as failing on mcp 0.8.** The note
168
+ naming a discarded `detail` value read `meta` off the response, which the
169
+ 0.8 SDK has no reader for, so building the note raised and the tool
170
+ answered `Tool ... failed:`.
171
+
8
172
  ## [5.19.1] - 2026-08-10
9
173
 
10
174
  ### 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. An introspector answers in this tier only if it defines `static_call`. Two shapes count as defining it: reading a different source (parsing `db/schema.rb` instead of querying the connection), and reading the same source a booted app would (a file-based introspector, where `static_call` is the same work under another name).
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. Add the key to the appropriate preset(s) in `Configuration::PRESETS` (`:full` is the default, `:standard` for core-only)
37
- 5. Write specs in `spec/lib/rails_ai_context/your_introspector_spec.rb`
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. Auto-registered - no manual list to update (BaseTool.inherited tracks it)
45
- 5. Write specs in `spec/lib/rails_ai_context/tools/your_tool_spec.rb`
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. If your listener needs an event `SourceIntrospector.register_listener` doesn't already wire up, add it there
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 do
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!
@@ -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:** `>= 0.28, < 2.0`
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
@@ -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. Register the event in `SourceIntrospector.register_listener` if it is not already in the list (call, def, class, module, block, singleton class, case, constant write).
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
- ## Log redaction
193
+ ## Redaction
194
194
 
195
- The `rails_read_logs` tool redacts sensitive data from log output:
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
- - Passwords and tokens
198
- - Email addresses
199
- - Secret values
200
- - API keys
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 other 43 tools listed.
301
+ You should see `rails_get_schema`, `rails_search_code`, and the rest of the tools listed.
302
302
 
303
303
  ### Cursor
304
304
 
@@ -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 [REDACTED]"
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.