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 +4 -4
- data/CHANGELOG.md +36 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +20 -3
- data/lib/rails_ai_context/introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +20 -1
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +15 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +67 -6
- data/lib/rails_ai_context/introspectors/job_introspector.rb +71 -1
- data/lib/rails_ai_context/introspectors/route_introspector.rb +5 -1
- data/lib/rails_ai_context/introspectors/static_tier.rb +9 -0
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +6 -5
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +4 -2
- data/lib/rails_ai_context/server.rb +45 -5
- data/lib/rails_ai_context/tools/get_active_support.rb +16 -0
- data/lib/rails_ai_context/tools/get_engines.rb +3 -1
- data/lib/rails_ai_context/tools/get_env.rb +20 -0
- data/lib/rails_ai_context/tools/get_gems.rb +4 -1
- data/lib/rails_ai_context/tools/get_i18n.rb +3 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +2 -1
- data/lib/rails_ai_context/tools/get_routes.rb +20 -11
- data/lib/rails_ai_context/tools/section_fetch.rb +1 -3
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7cbf76ef448db45165cf06f3c2af8303b23f8629a709b20ffb61ae33093c192
|
|
4
|
+
data.tar.gz: 65fe97840d3fd23ab66b1b8e6b108914b3675e3648c51d746769d4016b6a846a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
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 :
|
|
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 :
|
|
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(
|
|
90
|
-
locales.reject { |l| l ==
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, _|
|
|
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, _|
|
|
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
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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?
|
data/server.json
CHANGED