rails-ai-context 5.20.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e92439f72b8f723d75c6b62ca2fe3734f29b60548e50d2ddea836c93a66d952
4
- data.tar.gz: c541d140c1f7971fa0f62d5e664673a338ffc7e809b8df8578035890224567d0
3
+ metadata.gz: e7cbf76ef448db45165cf06f3c2af8303b23f8629a709b20ffb61ae33093c192
4
+ data.tar.gz: 65fe97840d3fd23ab66b1b8e6b108914b3675e3648c51d746769d4016b6a846a
5
5
  SHA512:
6
- metadata.gz: e2e550d201dd7af30d4e95e5ec368d2f376d2b8812d5254ffcf10ab8f78b1f0e089df5ba5f2664fbe858dbf2a5277c15e2489df0e86b61b22ebd44868560854f
7
- data.tar.gz: 80daba010490cd5b6d1e0c4e770582b4bda8db4d073ebb9bead8d2eb3f015ec966c1ce1fef60f229755e1f1610b48b2287da480d790f20ed3be705d8b4ee95d7
6
+ metadata.gz: b4816a51648a6aaee1a3fd48b649ff35967e2efea2c75805da08c605a0668b8afa7dbe113b8d7f555a13c268d72f1284b69091a3b9d102465e3a6b00f5b2282b
7
+ data.tar.gz: cc12eaa6400a9a2906ca706b37fc92f211f0837e69038f14e6a25742be8207d529c915c4ecdb122c6bd2a20a9dc0eb86a3474cd4a65a321fa8ded9f2aa64a47d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ 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
+
8
44
  ## [5.20.0] - 2026-08-11
9
45
 
10
46
  ### Security
@@ -15,6 +15,13 @@ module RailsAiContext
15
15
  class ToolNotFoundError < StandardError; end
16
16
  class InvalidArgumentError < StandardError; end
17
17
 
18
+ # The only words that read as true, and every word a boolean flag will
19
+ # consume as its value. Kept together so the two cannot drift: a word
20
+ # accepted here but missing from TRUTHY would silently mean false.
21
+ TRUTHY_WORDS = %w[true 1 yes].freeze
22
+ FALSEY_WORDS = %w[false 0 no].freeze
23
+ BOOLEAN_WORDS = (TRUTHY_WORDS + FALSEY_WORDS).freeze
24
+
18
25
  attr_reader :tool_class, :raw_args, :json_mode, :error
19
26
 
20
27
  def initialize(tool_name, raw_args, json_mode: false)
@@ -210,8 +217,18 @@ module RailsAiContext
210
217
  prop = properties[key] || {}
211
218
 
212
219
  if prop[:type] == "boolean"
213
- result[key] = true
214
- i += 1
220
+ # `--flag false` is the form docs/CLI.md teaches for every
221
+ # other param, so a boolean has to honour it too. Only an
222
+ # explicit boolean word is consumed - anything else stays a
223
+ # separate argument and the flag means true.
224
+ nxt = args[i + 1]
225
+ if nxt && BOOLEAN_WORDS.include?(nxt.downcase)
226
+ result[key] = coerce_value(nxt, prop)
227
+ i += 2
228
+ else
229
+ result[key] = true
230
+ i += 1
231
+ end
215
232
  next
216
233
  end
217
234
 
@@ -247,7 +264,7 @@ module RailsAiContext
247
264
  when "integer"
248
265
  raw.to_i
249
266
  when "boolean"
250
- %w[true 1 yes].include?(raw.to_s.downcase)
267
+ TRUTHY_WORDS.include?(raw.to_s.downcase)
251
268
  when "array"
252
269
  raw.is_a?(Array) ? raw : raw.to_s.split(",").map(&:strip)
253
270
  else
@@ -122,9 +122,7 @@ module RailsAiContext
122
122
  end
123
123
 
124
124
  def unavailable_reason
125
- reason = RailsAiContext.static_reason
126
- base = "requires a booted Rails app"
127
- reason ? "#{base} (#{reason})" : base
125
+ Introspectors::StaticTier.unavailable_reason
128
126
  end
129
127
 
130
128
  def rails_version
@@ -8,7 +8,7 @@ module RailsAiContext
8
8
  # Covers RAILS_NERVOUS_SYSTEM.md §17 (ActiveSupport).
9
9
  class ActiveSupportIntrospector
10
10
  extend StaticTier
11
- static_tier :files_only
11
+ static_tier :alternate_source
12
12
 
13
13
  attr_reader :app
14
14
 
@@ -30,6 +30,25 @@ module RailsAiContext
30
30
  { error: e.message }
31
31
  end
32
32
 
33
+ # Concerns, MessageVerifier usage and tagged logging are read off disk.
34
+ # The registered deprecators, the subscribed load hooks and the cache
35
+ # store only exist in a running process; an empty list for them renders
36
+ # as "this app has none", so they refuse instead.
37
+ def static_call
38
+ unavailable = { unavailable: StaticTier.unavailable_reason }
39
+ {
40
+ concerns: extract_concerns,
41
+ deprecators: unavailable,
42
+ message_verifier_usage: extract_message_verifier_usage,
43
+ tagged_logging: detect_tagged_logging,
44
+ on_load_hooks: unavailable,
45
+ cache_usage: unavailable
46
+ }
47
+ rescue => e
48
+ $stderr.puts "[rails-ai-context] ActiveSupportIntrospector#static_call failed: #{e.message}" if ENV["DEBUG"]
49
+ { error: e.message }
50
+ end
51
+
33
52
  private
34
53
 
35
54
  def root
@@ -6,7 +6,7 @@ module RailsAiContext
6
6
  # Identifies well-known engines and provides context about what each does.
7
7
  class EngineIntrospector
8
8
  extend StaticTier
9
- static_tier :files_only
9
+ static_tier :alternate_source
10
10
 
11
11
  attr_reader :app
12
12
 
@@ -50,6 +50,20 @@ module RailsAiContext
50
50
  { error: e.message }
51
51
  end
52
52
 
53
+ # config/routes.rb is a file, so mounts read the same either way. Which
54
+ # engine classes a process loaded is only knowable from that process -
55
+ # and testing `defined?(Rails::Engine)` instead of the tier answered from
56
+ # the half-finished boot that put us here, which is the common way into
57
+ # the static tier.
58
+ def static_call
59
+ {
60
+ mounted_engines: discover_mounted_engines,
61
+ rails_engines: { unavailable: StaticTier.unavailable_reason }
62
+ }
63
+ rescue => e
64
+ { error: e.message }
65
+ end
66
+
53
67
  private
54
68
 
55
69
  def root
@@ -7,7 +7,12 @@ module RailsAiContext
7
7
  # Discovers internationalization setup: locales, backends, key counts.
8
8
  class I18nIntrospector
9
9
  extend StaticTier
10
- static_tier :files_only
10
+ static_tier :alternate_source
11
+
12
+ # Both spellings apps use: `config.i18n.default_locale = :es` in
13
+ # application.rb or an environment file, and a bare
14
+ # `I18n.default_locale = :es` in an initializer.
15
+ DEFAULT_LOCALE_ASSIGNMENT = /(?:config\.i18n|I18n)\.default_locale\s*=\s*[:"']([\w-]+)/
11
16
 
12
17
  attr_reader :app
13
18
 
@@ -30,8 +35,65 @@ module RailsAiContext
30
35
  { error: e.message }
31
36
  end
32
37
 
38
+ # The locale files are the same files either way; only the list of
39
+ # locales and the default came from a running I18n. Read both from disk
40
+ # rather than report the library's own defaults as the app's.
41
+ def static_call
42
+ locales = locales_from_files
43
+ default = default_locale_from_config
44
+
45
+ {
46
+ default_locale: default,
47
+ available_locales: locales,
48
+ backend: nil,
49
+ locale_files: extract_locale_files,
50
+ total_locale_files: count_locale_files,
51
+ locale_coverage: detect_locale_coverage(locales: locales.map(&:to_sym), default: default.to_sym)
52
+ }.merge(detect_fallback_config)
53
+ rescue => e
54
+ { error: e.message }
55
+ end
56
+
33
57
  private
34
58
 
59
+ # Every top-level key across config/locales - the same population Rails
60
+ # builds available_locales from.
61
+ def locales_from_files
62
+ dir = File.join(root, "config/locales")
63
+ return [] unless Dir.exist?(dir)
64
+
65
+ Dir.glob(File.join(dir, "**/*.{yml,yaml}")).flat_map do |path|
66
+ content = RailsAiContext::SafeFile.read(path)
67
+ next [] unless content
68
+
69
+ # aliases: true, like extract_locale_files. Sharing formats through a
70
+ # YAML anchor is ordinary, and without the flag Psych raises, the
71
+ # rescue swallows it, and every locale in that file disappears while
72
+ # the Locale Files section still lists it.
73
+ data = YAML.safe_load(content, permitted_classes: [ Symbol ], aliases: true)
74
+ data.is_a?(Hash) ? data.keys.map(&:to_s) : []
75
+ rescue StandardError
76
+ []
77
+ end.uniq.sort
78
+ end
79
+
80
+ # Rails' own default is :en, so "en" is the right answer when the app
81
+ # never says otherwise - not a guess.
82
+ def default_locale_from_config
83
+ candidates = [ File.join(root, "config", "application.rb") ] +
84
+ Dir.glob(File.join(root, "config", "environments", "*.rb")) +
85
+ Dir.glob(File.join(root, "config", "initializers", "*.rb"))
86
+
87
+ candidates.each do |path|
88
+ next unless File.exist?(path)
89
+
90
+ content = RailsAiContext::SafeFile.read(path)
91
+ match = content&.match(DEFAULT_LOCALE_ASSIGNMENT)
92
+ return match[1] if match
93
+ end
94
+ "en"
95
+ end
96
+
35
97
  def root
36
98
  app.root.to_s
37
99
  end
@@ -77,8 +139,7 @@ module RailsAiContext
77
139
  {}
78
140
  end
79
141
 
80
- def detect_locale_coverage
81
- locales = I18n.available_locales
142
+ def detect_locale_coverage(locales: I18n.available_locales, default: I18n.default_locale)
82
143
  return {} if locales.size < 2
83
144
 
84
145
  # Coverage is the share of the default locale's keys that the other
@@ -86,8 +147,8 @@ module RailsAiContext
86
147
  # for a locale that translates few default keys but adds many of its
87
148
  # own - the one number a translator must not be told is fine.
88
149
  coverage = {}
89
- default_keys = key_paths_for_locale(I18n.default_locale)
90
- locales.reject { |l| l == I18n.default_locale }.each do |locale|
150
+ default_keys = key_paths_for_locale(default)
151
+ locales.reject { |l| l == default }.each do |locale|
91
152
  locale_keys = key_paths_for_locale(locale)
92
153
  translated = (default_keys & locale_keys).size
93
154
  coverage[locale.to_s] = {
@@ -110,7 +171,7 @@ module RailsAiContext
110
171
  find_locale_paths(locale).flat_map do |path|
111
172
  content = RailsAiContext::SafeFile.read(path)
112
173
  next [] unless content
113
- data = YAML.safe_load(content, permitted_classes: [ Symbol ])
174
+ data = YAML.safe_load(content, permitted_classes: [ Symbol ], aliases: true)
114
175
  next [] unless data.is_a?(Hash)
115
176
 
116
177
  # A locale root may be written `en:` or `:en:` - both load, and both
@@ -6,7 +6,7 @@ module RailsAiContext
6
6
  # and Action Cable channels.
7
7
  class JobIntrospector
8
8
  extend StaticTier
9
- static_tier :files_only
9
+ static_tier :alternate_source
10
10
 
11
11
  CHANNEL_MACROS = %i[identified_by stream_from stream_for periodically].freeze
12
12
 
@@ -31,6 +31,19 @@ module RailsAiContext
31
31
  }
32
32
  end
33
33
 
34
+ # Mailers and channels are ordinary classes in ordinary directories, so
35
+ # the answer is the same with or without a booted app. Only the way in
36
+ # differs: descendants when Rails is up, the AST when it is not.
37
+ def static_call
38
+ {
39
+ jobs: extract_jobs_from_source,
40
+ mailers: extract_mailers_from_source,
41
+ channels: extract_channels_from_source,
42
+ recurring_jobs: extract_solid_queue_recurring,
43
+ sidekiq_config: extract_sidekiq_config
44
+ }
45
+ end
46
+
34
47
  private
35
48
 
36
49
  def extract_jobs
@@ -235,6 +248,63 @@ module RailsAiContext
235
248
  []
236
249
  end
237
250
 
251
+ # A mailer's actions are its public instance methods, which is exactly
252
+ # what `instance_methods(false)` reports on the booted side.
253
+ def extract_mailers_from_source
254
+ source_classes(File.join(app.root, "app", "mailers")).filter_map do |name, methods|
255
+ next if name == "ApplicationMailer"
256
+
257
+ actions = methods.select { |m| m[:scope] == :instance && m[:visibility] == :public }
258
+ .map { |m| m[:name] }.sort
259
+ next if actions.empty?
260
+
261
+ { name: name, actions: actions, confidence: RailsAiContext::Confidence::STATIC }
262
+ end.sort_by { |m| m[:name] }
263
+ end
264
+
265
+ def extract_channels_from_source
266
+ # ApplicationCable holds the base Channel and Connection, neither of
267
+ # which is a channel of the app's own.
268
+ source_classes(File.join(app.root, "app", "channels")).filter_map do |name, methods|
269
+ next if name.start_with?("ApplicationCable::")
270
+
271
+ stream_methods = methods.select { |m| m[:scope] == :instance }
272
+ .map { |m| m[:name] }
273
+ .select { |m| m.start_with?("stream_") || m == "subscribed" }
274
+ { name: name, stream_methods: stream_methods, confidence: RailsAiContext::Confidence::STATIC }
275
+ end.sort_by { |c| c[:name] }
276
+ end
277
+
278
+ # Class name plus method list for every .rb under `dir`, read from the
279
+ # AST. Yields nothing when the directory is absent.
280
+ #
281
+ # The name comes from the path, not the AST: Zeitwerk requires the two to
282
+ # agree, and only the path carries the namespace. Reading `class Channel`
283
+ # out of `application_cable/channel.rb` yields "Channel", which matches
284
+ # no base-class filter and no name the booted app would report.
285
+ def source_classes(dir)
286
+ return [] unless Dir.exist?(dir)
287
+
288
+ Dir.glob(File.join(dir, "**/*.rb")).sort.filter_map do |path|
289
+ next unless File.exist?(path) && File.size(path) > 0
290
+ next if File.size(path) > RailsAiContext.configuration.max_file_size
291
+ # Rails adds app/*/concerns as its own autoload root, so what lives
292
+ # there is a mixin, not a mailer or a channel - and naming it from
293
+ # the path would invent a `Concerns::` namespace that never exists.
294
+ next if path.sub("#{dir}/", "").start_with?("concerns/")
295
+
296
+ walked = SourceIntrospector.walk(path, { methods: Listeners::MethodsListener })
297
+ [ constant_name_for(path, dir), walked[:methods] || [] ]
298
+ rescue StandardError, ScriptError => e
299
+ $stderr.puts "[rails-ai-context] source_classes failed for #{path}: #{e.message}" if ENV["DEBUG"]
300
+ nil
301
+ end
302
+ end
303
+
304
+ def constant_name_for(path, dir)
305
+ path.sub("#{dir}/", "").sub(/\.rb\z/, "").camelize
306
+ end
307
+
238
308
  def extract_channels
239
309
  return [] unless defined?(ActionCable::Channel::Base)
240
310
 
@@ -20,7 +20,11 @@ module RailsAiContext
20
20
  root = routes.find { |r| r[:path] == "/" && r[:verb]&.include?("GET") }
21
21
 
22
22
  {
23
- total_routes: routes.size,
23
+ # Merged, because that is how every surface that lists routes counts
24
+ # them: Rails registers PATCH and PUT separately for one update
25
+ # action, and a raw total here left the generated context files
26
+ # quoting a grand total their own app-route number cannot reach.
27
+ total_routes: Tools::BaseTool.dedupe_put_patch_routes(routes).size,
24
28
  by_controller: group_by_controller(routes),
25
29
  api_namespaces: detect_api_namespaces(routes),
26
30
  mounted_engines: detect_mounted_engines,
@@ -18,6 +18,15 @@ module RailsAiContext
18
18
 
19
19
  KINDS = [ FILES_ONLY, RUNTIME_ONLY, ALTERNATE_SOURCE ].freeze
20
20
 
21
+ # One wording for "the app did not boot", carrying whatever the boot
22
+ # failure said. Forked copies drift, and a single run then emits two
23
+ # different explanations for the same condition.
24
+ def self.unavailable_reason
25
+ reason = RailsAiContext.static_reason
26
+ base = "requires a booted Rails app"
27
+ reason ? "#{base} (#{reason})" : base
28
+ end
29
+
21
30
  def static_tier(kind = nil)
22
31
  return declared_static_tier if kind.nil?
23
32
 
@@ -36,13 +36,14 @@ module RailsAiContext
36
36
 
37
37
  routes = context[:routes]
38
38
  if routes && !routes[:error]
39
- internal = %w[action_mailbox/ active_storage/ rails/ conductor/ devise/ turbo/]
39
+ # From config, like rails_get_routes and rails_onboard. Hardcoding it
40
+ # here made one documented option enough to reopen the count drift.
41
+ internal = RailsAiContext.configuration.excluded_route_prefixes
40
42
  by_controller = routes[:by_controller] || {}
41
43
  app_ctrls = by_controller.keys.reject { |k| internal.any? { |p| k.downcase.start_with?(p) } }
42
- # Numerator and denominator must describe the same population: app
43
- # routes across app controllers, with the grand total (framework
44
- # routes included) alongside for scale.
45
- app_routes = app_ctrls.sum { |k| Array(by_controller[k]).size }
44
+ # Deduped, so this number, the grand total beside it, and
45
+ # rails_get_routes' header all describe the same population.
46
+ app_routes = app_ctrls.sum { |k| Tools::BaseTool.dedupe_put_patch_routes(Array(by_controller[k])).size }
46
47
  lines << "- Routes: #{count_phrase(app_routes, "app route")} across " \
47
48
  "#{count_phrase(app_ctrls.size, "controller")} " \
48
49
  "(#{routes[:total_routes]} total incl. framework)"
@@ -39,10 +39,12 @@ module RailsAiContext
39
39
  if routes && !routes[:error]
40
40
  # Same population as the compact serializers: app routes across app
41
41
  # controllers, so every generated context file quotes one number.
42
- internal = %w[action_mailbox/ active_storage/ rails/ conductor/ devise/ turbo/]
42
+ # From config, like rails_get_routes and rails_onboard. Hardcoding it
43
+ # here made one documented option enough to reopen the count drift.
44
+ internal = RailsAiContext.configuration.excluded_route_prefixes
43
45
  by_controller = routes[:by_controller] || {}
44
46
  app_ctrls = by_controller.keys.reject { |k| internal.any? { |p| k.downcase.start_with?(p) } }
45
- app_routes = app_ctrls.sum { |k| Array(by_controller[k]).size }
47
+ app_routes = app_ctrls.sum { |k| Tools::BaseTool.dedupe_put_patch_routes(Array(by_controller[k])).size }
46
48
  lines << "- Routes: #{count_phrase(app_routes, "app route")} across " \
47
49
  "#{count_phrase(app_ctrls.size, "controller")} " \
48
50
  "(#{routes[:total_routes]} total incl. framework)"
@@ -112,7 +112,7 @@ module RailsAiContext
112
112
  name: config.server_name,
113
113
  version: config.server_version,
114
114
  instructions: "Ground truth engine for Rails apps. Live Prism AST introspection. Zero stale data.",
115
- tools: active_tools(config) + validated_custom_tools,
115
+ tools: merge_tools(active_tools(config), validated_custom_tools),
116
116
  resource_templates: Resources.resource_templates,
117
117
  configuration: mcp_config
118
118
  )
@@ -146,12 +146,53 @@ module RailsAiContext
146
146
  tools.reject { |t| skip.include?(t.tool_name) }
147
147
  end
148
148
 
149
+ # The MCP SDK refuses a tool list with a repeated name, so one duplicate
150
+ # takes the whole server down. Two ways they arise:
151
+ #
152
+ # - The same class twice. Naming a BaseTool subclass in custom_tools
153
+ # resolves its constant, which autoloads the file, which fires
154
+ # `inherited` and enrols it in the registry active_tools reads.
155
+ # - Two different classes claiming one name. Deliberate replacement is
156
+ # spelled with skip_tools; without it, keep the built-in and say so.
157
+ def merge_tools(builtin, custom)
158
+ merged = (builtin + custom).uniq
159
+ builtin_names = builtin.to_set { |t| tool_label(t) }
160
+
161
+ merged.group_by { |t| tool_label(t) }.flat_map do |name, tools|
162
+ next tools if tools.size == 1
163
+
164
+ # Only skip_tools can settle a clash with a built-in; when both
165
+ # claimants are custom there is no built-in to skip, and saying so
166
+ # would send the user after a setting that cannot help.
167
+ advice = if builtin_names.include?(name)
168
+ "keeping the built-in. Add #{name.inspect} to config.skip_tools to replace it."
169
+ else
170
+ "keeping the first. Give one of them a different tool_name."
171
+ end
172
+ $stderr.puts "[rails-ai-context] WARNING: #{tools.size} tools claim the name #{name.inspect}; #{advice}"
173
+ [ tools.first ]
174
+ end
175
+ end
176
+
177
+ # MCP::Tool subclasses answer tool_name; anything else falls back to the
178
+ # class name, which is what the SDK would key on anyway.
179
+ def tool_label(tool)
180
+ tool.respond_to?(:tool_name) ? tool.tool_name : tool.name
181
+ end
182
+
183
+ # Read the list off the server rather than rebuilding it. Recomputing it
184
+ # from the registry drops any custom tool that is not a BaseTool, so the
185
+ # banner announced a different set than the server answered with.
186
+ def tool_banner(server)
187
+ names = server.tools.values.map { |t| tool_label(t) }.sort
188
+ "[rails-ai-context] Tools (#{names.size}): #{names.join(', ')}"
189
+ end
190
+
149
191
  def start_stdio(server)
150
192
  transport = MCP::Server::Transports::StdioTransport.new(server)
151
- tools = active_tools(RailsAiContext.configuration)
152
193
  # Log to stderr so we don't pollute the JSON-RPC channel on stdout
153
194
  $stderr.puts "[rails-ai-context] MCP server started (stdio transport)"
154
- $stderr.puts "[rails-ai-context] Tools (#{tools.size}): #{tools.map { |t| t.tool_name }.join(', ')}"
195
+ $stderr.puts tool_banner(server)
155
196
  maybe_start_live_reload(server)
156
197
  transport.open
157
198
  end
@@ -169,9 +210,8 @@ module RailsAiContext
169
210
  "this exposes all tools to the network without authentication. " \
170
211
  "Use 127.0.0.1 (default) unless you have external auth in place."
171
212
  end
172
- tools = active_tools(config)
173
213
  $stderr.puts "[rails-ai-context] MCP server starting on #{config.http_bind}:#{config.http_port}#{config.http_path}"
174
- $stderr.puts "[rails-ai-context] Tools (#{tools.size}): #{tools.map { |t| t.tool_name }.join(', ')}"
214
+ $stderr.puts tool_banner(server)
175
215
  maybe_start_live_reload(server)
176
216
 
177
217
  begin
@@ -57,7 +57,19 @@ module RailsAiContext
57
57
  end
58
58
  end
59
59
 
60
+ # A section the introspector could not reach says so under its own
61
+ # heading. Silently omitting it reads as "nothing here".
62
+ def render_unavailable(lines, title, data)
63
+ return false unless data.is_a?(Hash) && data[:unavailable]
64
+
65
+ lines << "" << "## #{title}"
66
+ lines << RailsAiContext::Confidence.unavailable(data[:unavailable])
67
+ true
68
+ end
69
+
60
70
  def render_simple_list(lines, title, items)
71
+ return if render_unavailable(lines, title, items)
72
+
61
73
  items = Array(items)
62
74
  return if items.empty?
63
75
 
@@ -88,6 +100,8 @@ module RailsAiContext
88
100
  end
89
101
 
90
102
  def render_on_load_hooks(lines, hooks)
103
+ return if render_unavailable(lines, "Subscribed on_load Hooks", hooks)
104
+
91
105
  hooks = Array(hooks)
92
106
  return if hooks.empty?
93
107
 
@@ -96,6 +110,8 @@ module RailsAiContext
96
110
  end
97
111
 
98
112
  def render_cache(lines, cache)
113
+ return if render_unavailable(lines, "Cache Store", cache)
114
+
99
115
  cache = cache || {}
100
116
  return if cache.empty? || cache[:store].to_s.empty?
101
117
 
@@ -37,7 +37,9 @@ module RailsAiContext
37
37
  end
38
38
 
39
39
  lines << "" << "## Loaded Engine Classes"
40
- if loaded.any?
40
+ if loaded.is_a?(Hash) && loaded[:unavailable]
41
+ lines << RailsAiContext::Confidence.unavailable(loaded[:unavailable])
42
+ elsif loaded.any?
41
43
  loaded.each do |e|
42
44
  parts = []
43
45
  parts << count_phrase(e[:route_count], "route") if e[:route_count]
@@ -128,6 +128,10 @@ module RailsAiContext
128
128
  lines << "## Credentials Keys (values hidden)"
129
129
  credentials_keys.each { |k| lines << "- `#{k}`" }
130
130
  lines << ""
131
+ elsif credentials_file_present?
132
+ lines << "## Credentials Keys (values hidden)"
133
+ lines << RailsAiContext::Confidence.unavailable("credentials are encrypted; reading the key names needs a booted app with its master key")
134
+ lines << ""
131
135
  end
132
136
 
133
137
  # Encrypted columns
@@ -228,6 +232,10 @@ module RailsAiContext
228
232
  lines << "## Credentials Keys (values hidden)"
229
233
  credentials_keys.each { |k| lines << "- `#{k}`" }
230
234
  lines << ""
235
+ elsif credentials_file_present?
236
+ lines << "## Credentials Keys (values hidden)"
237
+ lines << RailsAiContext::Confidence.unavailable("credentials are encrypted; reading the key names needs a booted app with its master key")
238
+ lines << ""
231
239
  end
232
240
 
233
241
  # Encrypted columns
@@ -500,6 +508,18 @@ module RailsAiContext
500
508
  []
501
509
  end
502
510
 
511
+ # An encrypted credentials file the tool could not open is a different
512
+ # fact from an app with no credentials, and only one of them is true here.
513
+ private_class_method def self.credentials_file_present?
514
+ root = rails_app.root.to_s
515
+ # Rails 6+ apps commonly carry only per-environment credentials, with
516
+ # no top-level file at all.
517
+ File.exist?(File.join(root, "config", "credentials.yml.enc")) ||
518
+ Dir.glob(File.join(root, "config", "credentials", "*.yml.enc")).any?
519
+ rescue StandardError
520
+ false
521
+ end
522
+
503
523
  private_class_method def self.detect_credentials_keys
504
524
  keys = []
505
525
 
@@ -81,11 +81,14 @@ module RailsAiContext
81
81
  line += " _(config: #{config_hint})_" if config_hint
82
82
  lines << line
83
83
  end
84
- else
84
+ elsif page[:total].zero?
85
85
  all_cats = (gems[:notable_gems] || []).map { |g| g[:category] }.uniq.sort
86
86
  hint = all_cats.any? ? " Available categories: #{all_cats.join(', ')}" : ""
87
87
  lines << "_No notable gems found#{" in category '#{category}'" unless category == 'all'}.#{hint}_"
88
88
  end
89
+ # An empty page past the end is not an empty app. Saying "no notable
90
+ # gems found" above "No items at offset 9999. Total: 13." contradicts
91
+ # itself, and the first line is the one that reads as the answer.
89
92
 
90
93
  lines << "" << page[:hint] unless page[:hint].empty?
91
94
  text_response(lines.join("\n"))
@@ -58,7 +58,9 @@ module RailsAiContext
58
58
  lines = [ "# I18n" ]
59
59
  lines << ""
60
60
  lines << "- **Default locale:** #{i18n[:default_locale]}"
61
- lines << "- **Backend:** #{i18n[:backend]}"
61
+ # The backend class is a runtime fact. Printing the I18n gem's own
62
+ # default when no app booted states it as the app's choice.
63
+ lines << "- **Backend:** #{i18n[:backend]}" if i18n[:backend]
62
64
  lines << "- **Available locales:** #{available_list(i18n)}"
63
65
  lines << "- **Locale files:** #{i18n[:total_locale_files] || files.size}"
64
66
 
@@ -52,7 +52,8 @@ module RailsAiContext
52
52
  if page[:items].any?
53
53
  page[:items].each do |m|
54
54
  lines << "" << "## #{m[:name]}"
55
- lines << "- **Delivery method:** #{m[:delivery_method]}"
55
+ # Configured at boot, so the static tier has no value to give.
56
+ lines << "- **Delivery method:** #{m[:delivery_method]}" if m[:delivery_method].present?
56
57
  lines << "- **Actions:** #{Array(m[:actions]).join(', ')}"
57
58
  end
58
59
  else
@@ -34,6 +34,10 @@ module RailsAiContext
34
34
  }
35
35
  )
36
36
 
37
+ def self.framework_controller?(name)
38
+ route_prefixes.any? { |p| name.downcase.start_with?(p) }
39
+ end
40
+
37
41
  def self.route_prefixes
38
42
  RailsAiContext.configuration.excluded_route_prefixes
39
43
  end
@@ -61,9 +65,9 @@ module RailsAiContext
61
65
  # were dropped so headers can say the count is app-only, not the total.
62
66
  excluded_framework_count = 0
63
67
  if app_only
64
- framework_ctrls = by_controller.select { |k, _| route_prefixes.any? { |p| k.downcase.start_with?(p) } }
68
+ framework_ctrls = by_controller.select { |k, _| framework_controller?(k) }
65
69
  excluded_framework_count = framework_ctrls.values.sum { |actions| dedupe_put_patch_routes(actions).size }
66
- by_controller = by_controller.reject { |k, _| route_prefixes.any? { |p| k.downcase.start_with?(p) } }
70
+ by_controller = by_controller.reject { |k, _| framework_controller?(k) }
67
71
  end
68
72
 
69
73
  # Filter by controller - accepts "posts", "PostsController", "posts_controller", "Api::V1::Posts"
@@ -133,11 +137,17 @@ module RailsAiContext
133
137
  text_response(lines.join("\n"))
134
138
 
135
139
  when "standard"
136
- # Flatten all routes into a tagged array, then paginate
137
- app_routes = controller ? by_controller : by_controller.reject { |k, _| route_prefixes.any? { |p| k.downcase.start_with?(p) } }
138
- framework_routes = controller ? {} : by_controller.select { |k, _| route_prefixes.any? { |p| k.downcase.start_with?(p) } }
139
-
140
- flat_routes = app_routes.sort.flat_map { |ctrl, actions| actions.map { |r| r.merge(_ctrl: ctrl) } }
140
+ # List whatever survived the app_only filter. Re-splitting here
141
+ # dropped the framework routes from the body while the header kept
142
+ # counting them, so `app_only:false` announced 50 routes and showed
143
+ # 24 of them.
144
+ #
145
+ # App controllers first: sorted plainly, `action_mailbox/` and
146
+ # `active_storage/` lead the alphabet, so on an app with more
147
+ # framework routes than the page limit the app's own would
148
+ # paginate out of sight.
149
+ ordered = by_controller.sort_by { |ctrl, _| [ framework_controller?(ctrl) ? 1 : 0, ctrl ] }
150
+ flat_routes = ordered.flat_map { |ctrl, actions| actions.map { |r| r.merge(_ctrl: ctrl) } }
141
151
  page = paginate(flat_routes, offset: offset, limit: limit, default_limit: 150)
142
152
 
143
153
  lines = [ "# Routes (#{count_label})", "" ]
@@ -172,10 +182,9 @@ module RailsAiContext
172
182
  lines << "- `#{r[:verb]}` `#{r[:path]}` → #{r[:action]}#{helper_part}#{params_part}"
173
183
  end
174
184
 
175
- if framework_routes.any?
176
- total_fw = framework_routes.values.sum(&:size)
177
- fw_names = framework_routes.keys.map { |k| k.split("/").first }.uniq.join(", ")
178
- lines << "" << "_#{fw_names} framework routes: #{total_fw} total (use `controller:\"devise/sessions\"` to see details)_"
185
+ if excluded_framework_count > 0 && controller.nil?
186
+ lines << "" << "_#{count_phrase(excluded_framework_count, "framework route")} hidden. " \
187
+ "Use `app_only:false` to include them._"
179
188
  end
180
189
 
181
190
  lines << "" << page[:hint] unless page[:hint].empty?
@@ -72,9 +72,7 @@ module RailsAiContext
72
72
  end
73
73
 
74
74
  def unavailable_static_reason
75
- reason = RailsAiContext.static_reason
76
- base = "requires a booted Rails app"
77
- reason ? "#{base} (#{reason})" : base
75
+ Introspectors::StaticTier.unavailable_reason
78
76
  end
79
77
  end
80
78
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.20.0"
4
+ VERSION = "5.20.1"
5
5
  end
data/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "5.19.1",
10
+ "version": "5.20.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
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.20.0
4
+ version: 5.20.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine