rails-ai-context 5.35.0 → 5.35.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. metadata +14 -3
@@ -25,13 +25,16 @@ module RailsAiContext
25
25
  attr_reader :tool_class, :raw_args, :json_mode, :error
26
26
 
27
27
  def initialize(tool_name, raw_args, json_mode: false, environment: nil)
28
- @tool_class = resolve_tool(tool_name)
28
+ RunCache.around do
29
+ @custom_tools = Server.resolve_custom_tools
30
+ @tool_class, @retired = resolve_tool(tool_name)
31
+ end
29
32
  @raw_args = raw_args
30
33
  @json_mode = json_mode
31
34
  @environment = environment
32
35
  @error = false
33
36
  @missing_required = false
34
- @invalid_required = []
37
+ @refused = []
35
38
  @out_of_type = {}
36
39
  # How each param was typed, so a suggestion uses the same form.
37
40
  @flag_spellings = {}
@@ -43,20 +46,28 @@ module RailsAiContext
43
46
  # announced its Redis connection on stdout ahead of a --json envelope.
44
47
  def run
45
48
  OutputGuard.quarantine_stdout(across_exec: false) do
46
- kwargs = build_kwargs
47
- schema = tool_schema
48
- validate_kwargs!(kwargs, schema)
49
- next extract_output(invalid_required_response) if @invalid_required.any?
50
-
51
- response = tool_class.call(**kwargs)
52
- extract_output(response)
49
+ Server.with_custom_tools(@custom_tools) do
50
+ kwargs = build_kwargs
51
+ schema = tool_schema
52
+ validate_kwargs!(kwargs, schema)
53
+ next extract_output(refusal_response) if @refused.any?
54
+
55
+ response = tool_class.call(**kwargs)
56
+ response = Tools::GetConfig.with_retired_note(response, @retired, kwargs) if @retired
57
+ extract_output(response)
58
+ end
53
59
  end
54
60
  end
55
61
 
62
+ # The tool's help, or for a retired name its config section's.
63
+ def help
64
+ self.class.tool_help(tool_class, @retired)
65
+ end
66
+
56
67
  # List all available tools with short names and descriptions.
57
68
  def self.tool_list
58
69
  lines = [ "Available tools:", "" ]
59
- available_tools.each do |tool|
70
+ Server.exposed_tools.each do |tool|
60
71
  short = short_name(tool.tool_name)
61
72
  full = tool.description_value.to_s
62
73
  desc = full.truncate(79, separator: " ", omission: "...")
@@ -82,30 +93,25 @@ module RailsAiContext
82
93
  lines.join("\n")
83
94
  end
84
95
 
85
- # Filtered tool list respecting skip_tools config.
86
- # The MCP server's rule: skip_tools removes built-ins only, and a built-in
87
- # comes before a custom tool of the same name, so skip_tools is how a
88
- # custom tool replaces one.
89
- def self.available_tools
90
- skip = RailsAiContext.configuration.skip_tools
91
- Server.builtin_tools.reject { |t| skip.include?(t.tool_name) } + Server.resolve_custom_tools
92
- end
93
-
94
- # Generate help for a specific tool from its input_schema.
95
- def self.tool_help(tool_class)
96
- schema = tool_class.input_schema_value&.to_h || {}
96
+ # Generate help for a specific tool from its input_schema. A retired
97
+ # name gets its own section's parameters, read from rails_get_config.
98
+ def self.tool_help(tool_class, retired = nil)
99
+ schema = retired ? Tools::GetConfig.schema_for([ retired ]) : tool_class.input_schema_value&.to_h || {}
97
100
  properties = schema[:properties] || {}
98
- required = schema[:required] || []
101
+ properties = properties.except(:section) if retired
102
+ required = retired ? [] : schema[:required] || []
103
+ name = retired ? retired.tool_name : tool_class.tool_name
104
+ about = retired ? "runs the `#{retired.name}` section of rails_get_config (#{retired.summary})." : tool_class.description_value
99
105
 
100
106
  lines = [
101
- "#{tool_class.tool_name} - #{tool_class.description_value}",
107
+ "#{name} - #{about}",
102
108
  "",
103
109
  "Usage:"
104
110
  ]
105
111
  # Standalone installs have no rake tasks; only show the rake form when
106
112
  # the gem lives in an app's Gemfile, not at an engine's root.
107
113
  form = RailsAiContext::InstallMode.form
108
- short = short_name(tool_class.tool_name)
114
+ short = short_name(name)
109
115
  if form == :tasks
110
116
  lines << " rails 'ai:tool[#{short}]' #{properties.keys.map { |k| "#{k}=VALUE" }.join(' ')}"
111
117
  end
@@ -135,59 +141,48 @@ module RailsAiContext
135
141
  tool_name.sub(/\Arails_get_/, "").sub(/\Arails_/, "")
136
142
  end
137
143
 
144
+ # The full names a typed name can spell: `schema`, `get_schema` and `rails_get_schema` are one tool.
145
+ def self.candidate_names(name)
146
+ [ name, "rails_#{name}", "rails_get_#{name}" ]
147
+ end
148
+
138
149
  private
139
150
 
140
- # InputSchema#to_h is stable across the mcp gem's >= 0.13, < 2.0 range;
141
- # the older #schema accessor was removed in mcp 0.20.0. to_h returns a
142
- # symbol-keyed hash carrying the :properties and :required this runner
143
- # reads (newer mcp also merges a benign :$schema key we ignore).
151
+ # The schema a call is checked against, as InputSchema#to_h gives it on every
152
+ # supported mcp. rails_get_config's takes a section skip_tools turned off.
144
153
  def tool_schema
145
- tool_class.input_schema_value&.to_h || {}
154
+ tool_class.input_schema&.to_h || {}
146
155
  end
147
156
 
148
- # The tool a name spells, loading that one class rather than all 45, in
149
- # available_tools' order: an unskipped built-in, then a custom tool. A
150
- # miss falls back to the full list, which also carries the suggestion.
157
+ # The tool a name spells and the config section a retired name stands for, loading
158
+ # that one class and no other. A miss falls back to the full list for the suggestion.
151
159
  def direct_tool(name)
152
- skip = RailsAiContext.configuration.skip_tools
153
- custom = Server.resolve_custom_tools
154
- [ name, "rails_#{name}", "rails_get_#{name}" ].each do |candidate|
155
- found = (builtin_tool(candidate) unless skip.include?(candidate)) ||
156
- custom.find { |tool| tool.tool_name == candidate }
157
- return found if found
160
+ self.class.candidate_names(name).each do |candidate|
161
+ found = Server.served_tool(candidate)
162
+ next unless found
163
+
164
+ # A retired config tool name runs its section, as the MCP server does.
165
+ section = Tools::GetConfig.retired_section(candidate)
166
+ return section && found.equal?(section.renderer) ? [ Tools::GetConfig, section ] : [ found, nil ]
158
167
  end
159
168
  nil
160
169
  end
161
170
 
162
- def builtin_tool(tool_name)
163
- const = tool_name.delete_prefix("rails_").split("_").map(&:capitalize).join
164
- return nil unless const.match?(/\A[A-Z]\w*\z/) && Tools.const_defined?(const, false)
165
-
166
- tool = Tools.const_get(const, false)
167
- tool if tool.is_a?(Class) && tool < Tools::BaseTool && !tool.abstract? && tool.tool_name == tool_name
168
- end
169
-
170
171
  # Resolve tool name: tries short → medium → full form.
171
172
  # "schema" → "rails_get_schema", "search_code" → "rails_search_code"
172
173
  def resolve_tool(name)
173
174
  direct = direct_tool(name)
174
175
  return direct if direct
175
176
 
176
- tools = self.class.available_tools
177
-
178
- [ name, "rails_#{name}", "rails_get_#{name}" ].each do |candidate|
179
- found = tools.find { |t| t.tool_name == candidate }
180
- return found if found
181
- end
182
-
177
+ tools = Server.exposed_tools
183
178
  found = tools.find { |t| self.class.short_name(t.tool_name) == name }
184
- return found if found
179
+ return [ found, nil ] if found
185
180
 
186
- skipped = [ name, "rails_#{name}", "rails_get_#{name}" ].find { |candidate| Server.skipped_tools.include?(candidate) }
187
- raise ToolNotFoundError, Server.skipped_tool_message(skipped) if skipped
181
+ skipped = self.class.candidate_names(name) & Server.skipped_tools
182
+ raise ToolNotFoundError, Server.skipped_tool_message(skipped.first) if skipped.any?
188
183
 
189
184
  # Fuzzy suggestion
190
- short_names = tools.map { |t| self.class.short_name(t.tool_name) }
185
+ short_names = (tools.map(&:tool_name) | Server.retired_names).map { |tool_name| self.class.short_name(tool_name) }
191
186
  suggestion = Tools::BaseTool.find_closest_match(name, short_names)
192
187
  msg = "Unknown tool '#{name}'."
193
188
  msg += " Did you mean '#{suggestion}'?" if suggestion
@@ -209,7 +204,7 @@ module RailsAiContext
209
204
  when Hash
210
205
  raw_args.transform_keys(&:to_sym).except(:server_context).transform_values { |value| utf8(value) }
211
206
  when Array
212
- return with_environment(parse_cli_args(raw_args).except(:server_context))
207
+ return with_environment(with_section(parse_cli_args(raw_args).except(:server_context)))
213
208
  else
214
209
  {}
215
210
  end
@@ -221,7 +216,11 @@ module RailsAiContext
221
216
  kwargs[key] = coerce_value(value, prop, key)
222
217
  end
223
218
 
224
- with_environment(kwargs)
219
+ with_environment(with_section(kwargs))
220
+ end
221
+
222
+ def with_section(kwargs)
223
+ @retired ? kwargs.merge(section: @retired.name) : kwargs
225
224
  end
226
225
 
227
226
  # `--environment` sets RAILS_ENV, so it never arrives as a tool argument; a tool that
@@ -229,10 +228,16 @@ module RailsAiContext
229
228
  def with_environment(kwargs)
230
229
  return kwargs if @environment.nil? || kwargs.key?(:environment)
231
230
  return kwargs unless (tool_schema[:properties] || {}).key?(:environment)
231
+ # Only a section that reads it gets it; the others answer for RAILS_ENV.
232
+ return kwargs if tool_class == Tools::GetConfig && !section_reads_environment?(kwargs[:section])
232
233
 
233
234
  kwargs.merge(environment: @environment)
234
235
  end
235
236
 
237
+ def section_reads_environment?(name)
238
+ Tools::GetConfig.section_named(name || "app")&.params&.include?(:environment)
239
+ end
240
+
236
241
  # Parse ["--table", "users", "--detail", "full", "--app-only"] into { table: "users", ... }
237
242
  def parse_cli_args(args)
238
243
  args = args.map { |arg| utf8(arg) }
@@ -246,7 +251,10 @@ module RailsAiContext
246
251
  if arg.start_with?("--no-")
247
252
  key = arg.sub("--no-", "").tr("-", "_").to_sym
248
253
  @flag_spellings[key] = arg
249
- result[key] = false
254
+ type = properties.dig(key, :type)
255
+ # Only a boolean has a false; an unknown flag passes through for the unknown-param message.
256
+ @out_of_type[key] = arg if type && type != "boolean" && key != :detail
257
+ result[key] = @out_of_type.key?(key) ? nil : false
250
258
  i += 1
251
259
  elsif arg.start_with?("--")
252
260
  if arg.include?("=")
@@ -341,16 +349,15 @@ module RailsAiContext
341
349
  end
342
350
  end
343
351
 
344
- # The flag a stray word was most likely meant for: a required param
345
- # first, since that is the one a caller must supply.
352
+ # The flag a stray word was most likely meant for, among the ones
353
+ # offered: a required param first, since a caller must supply it.
346
354
  def suggested_flag
347
- schema = tool_schema
348
- properties = (schema[:properties] || {})
349
- return nil if properties.empty?
355
+ offered = offered_params
356
+ return nil if offered.empty?
350
357
 
351
- required = (schema[:required] || []).map(&:to_s)
352
- name = properties.keys.find { |k| required.include?(k.to_s) } || properties.keys.first
353
- "--#{name.to_s.tr('_', '-')}"
358
+ required = (tool_schema[:required] || []).map(&:to_s)
359
+ name = offered.find { |param| required.include?(param) } || offered.first
360
+ "--#{name.tr('_', '-')}"
354
361
  end
355
362
 
356
363
  # A suggested param in the form the unknown one was typed: `--table`
@@ -360,10 +367,15 @@ module RailsAiContext
360
367
  end
361
368
 
362
369
  def valid_params_line
363
- keys = (tool_schema[:properties] || {}).keys.map(&:to_s)
370
+ keys = offered_params
364
371
  keys.any? ? "Valid params: #{keys.join(', ')}" : "This tool takes no params."
365
372
  end
366
373
 
374
+ # The parameters to name to the caller: the ones tools/list shows, or a retired name's own section's.
375
+ def offered_params
376
+ @retired ? @retired.params.map(&:to_s) : Tools::SafeCall.published_params(tool_class)
377
+ end
378
+
367
379
  def stray_argument_message(arg)
368
380
  flag = suggested_flag
369
381
  hint = flag ? " - did you mean '#{flag} #{arg}'?" : ""
@@ -394,21 +406,21 @@ module RailsAiContext
394
406
  [ tokens.flat_map { |a| Array(coerce_value(a, { type: "array" })) }, tokens.size ]
395
407
  end
396
408
 
397
- # Coerce a string value to the type specified in the JSON Schema property.
409
+ # Coerce a string value to its JSON Schema type. One of another type (`--limit abc`, `app_only=maybe`)
410
+ # is recorded for validation to refuse: coerced to 0 or false it answers a question nobody asked.
398
411
  def coerce_value(raw, property_schema, key = nil)
399
412
  case property_schema[:type]
400
413
  when "integer"
401
- # `--limit abc` is 0 through `to_i`, which answers a question
402
- # nobody asked. Record it instead, so validation refuses it the way
403
- # an out-of-enum value is refused.
404
- if raw.is_a?(Integer) || raw.to_s.strip.match?(/\A[-+]?\d+\z/)
405
- raw.to_i
406
- else
407
- @out_of_type[key] = raw if key
408
- nil
409
- end
414
+ return raw.to_i if raw.is_a?(Integer) || raw.to_s.strip.match?(/\A[-+]?\d+\z/)
415
+
416
+ @out_of_type[key] = raw if key
417
+ nil
410
418
  when "boolean"
411
- TRUTHY_WORDS.include?(raw.to_s.downcase)
419
+ return raw if raw == true || raw == false
420
+ return TRUTHY_WORDS.include?(raw.to_s.downcase) if BOOLEAN_WORDS.include?(raw.to_s.downcase)
421
+
422
+ @out_of_type[key] = raw if key
423
+ nil
412
424
  when "array"
413
425
  raw.is_a?(Array) ? raw : raw.to_s.split(",").map(&:strip)
414
426
  else
@@ -419,12 +431,13 @@ module RailsAiContext
419
431
  # Validate kwargs against the tool's input_schema.
420
432
  # For missing required params: strip empty values so the tool's own guards
421
433
  # can return a friendly response (matching MCP behavior).
422
- # For invalid enums: strip the bad value and let the tool use its default.
434
+ # For a value the parameter cannot take, by type or by list: refuse the call, except
435
+ # `detail`, which falls back with a warning.
423
436
  # For unknown params: raise InvalidArgumentError with closest-match suggestion.
424
437
  def validate_kwargs!(kwargs, schema)
425
438
  properties = schema[:properties] || {}
426
439
  required = (schema[:required] || []).map(&:to_s)
427
- known_keys = properties.keys.map(&:to_s)
440
+ known_keys = offered_params
428
441
 
429
442
  # Check for unknown params and raise a helpful error with suggestions.
430
443
  # server_context is always allowed (internal MCP param).
@@ -438,12 +451,9 @@ module RailsAiContext
438
451
  raise InvalidArgumentError, "Unknown param#{unknown.size > 1 ? 's' : ''}:\n#{msgs.join("\n")}\n#{valid_params_line}"
439
452
  end
440
453
 
441
- # A value the schema's type cannot hold is dropped with a warning, the
442
- # same treatment an out-of-enum value gets, so the tool applies its own
443
- # default rather than the zero `to_i` would invent.
444
454
  @out_of_type.each do |key, raw|
445
- prop = properties[key] || {}
446
- $stderr.puts "Warning: '#{raw}' is not a valid value for #{key}. Expected #{prop[:type]}. Using default."
455
+ type = properties.dig(key, :type)
456
+ refuse(key, raw, type == "boolean" ? "true or false" : properties.dig(key, :enum)&.join(", ") || "#{article(type)} #{type}")
447
457
  kwargs.delete(key)
448
458
  end
449
459
 
@@ -460,10 +470,8 @@ module RailsAiContext
460
470
  @missing_required = true
461
471
  end
462
472
 
463
- # Check enum constraints - downcase before comparing for case-insensitive match.
464
- # If invalid, strip the param so the tool uses its default behavior; a
465
- # required one has no default, so the call answers with the error itself
466
- # rather than letting the tool report the value as missing.
473
+ # A value outside the list answers another question, so the call is refused;
474
+ # every `detail` level answers the same one, so that falls back.
467
475
  kwargs.each do |key, value|
468
476
  prop = properties[key]
469
477
  next unless prop&.dig(:enum)
@@ -477,20 +485,23 @@ module RailsAiContext
477
485
  kwargs[key] = matched
478
486
  else
479
487
  valid = prop[:enum].join(", ")
480
- if required.include?(key.to_s)
481
- @invalid_required << "**Error:** `#{value}` is not a valid `#{key}`. Valid: #{valid}"
482
- @missing_required = true
483
- else
488
+ if key == :detail
484
489
  $stderr.puts "Warning: '#{value}' is not a valid value for #{key}. Valid: #{valid}. Using default."
485
490
  kwargs.delete(key)
491
+ else
492
+ refuse(key, value, valid)
486
493
  end
487
494
  end
488
495
  end
489
496
  end
490
497
 
498
+ def refuse(key, value, valid)
499
+ @refused << "**Error:** `#{value}` is not a valid `#{key}`. Valid: #{valid}"
500
+ end
501
+
491
502
  # Through the tool's own wrapper when it has one, so the static-tier banner rides along.
492
- def invalid_required_response
493
- text = @invalid_required.join("\n")
503
+ def refusal_response
504
+ text = @refused.join("\n")
494
505
  return tool_class.text_response(text) if tool_class <= Tools::BaseTool
495
506
 
496
507
  MCP::Tool::Response.new([ { type: "text", text: text } ])
@@ -508,7 +519,7 @@ module RailsAiContext
508
519
  # reported failure (isError), so callers can set a non-zero exit code.
509
520
  def extract_output(response)
510
521
  text = response.content.first&.dig(:text) || ""
511
- @error = @missing_required || (response.respond_to?(:error?) && response.error?)
522
+ @error = @missing_required || @refused.any? || (response.respond_to?(:error?) && response.error?)
512
523
  if json_mode
513
524
  require "json"
514
525
  JSON.pretty_generate(tool: tool_class.tool_name, output: text, error: @error)
@@ -50,6 +50,8 @@ module RailsAiContext
50
50
  return unless File.exist?(path)
51
51
 
52
52
  data = YAML.safe_load_file(path, permitted_classes: PERMITTED_YAML_CLASSES) || {}
53
+ return $stderr.puts("[rails-ai-context] WARNING: #{path}: the file holds no `key: value` settings. Ignored.") unless data.is_a?(Hash)
54
+
53
55
  config = RailsAiContext.configuration
54
56
 
55
57
  data.each do |key, value|
@@ -257,7 +259,7 @@ module RailsAiContext
257
259
  # metadata (method, tool_name, duration, error) is forwarded to
258
260
  # subscribers. When true, `tool_arguments` and `arguments` are forwarded
259
261
  # verbatim - including raw SQL from `rails_query`, env var names from
260
- # `rails_get_env`, and log search patterns from `rails_read_logs`.
262
+ # `rails_get_config(section: "env")`, and log search patterns from `rails_read_logs`.
261
263
  #
262
264
  # Setting this to true means the operator takes on the redaction
263
265
  # obligation for any downstream observability pipeline (Datadog, Scout,
@@ -379,7 +381,7 @@ module RailsAiContext
379
381
  attr_accessor :extra_app_paths
380
382
 
381
383
  # Database query tool settings (rails_query)
382
- attr_accessor :query_timeout # Statement timeout in seconds (default: 5)
384
+ attr_reader :query_timeout # Statement timeout in seconds, a positive number (default: 5)
383
385
  attr_reader :query_row_limit # Max rows returned (default: 100, hard cap: 1000)
384
386
  attr_accessor :query_redacted_columns # Column names a query may not reference, redacted in output
385
387
  attr_accessor :query_allowed_columns # Column names to exempt from the built-in sensitive list
@@ -459,12 +461,7 @@ module RailsAiContext
459
461
  @extra_app_paths = []
460
462
  @query_timeout = 5
461
463
  @query_row_limit = 100
462
- @query_redacted_columns = %w[
463
- password_digest encrypted_password password_hash
464
- reset_password_token confirmation_token unlock_token
465
- otp_secret session_data secret_key
466
- api_key api_secret access_token refresh_token jti
467
- ]
464
+ @query_redacted_columns = SecretColumns::DEFAULT_REDACTED_COLUMNS.dup
468
465
  @query_allowed_columns = []
469
466
  @allow_query_in_production = false
470
467
  @log_lines = 50
@@ -529,6 +526,16 @@ module RailsAiContext
529
526
  @max_tool_response_chars = value
530
527
  end
531
528
 
529
+ # A fraction of a second is kept as given: each database family rounds it up to the least it can bound.
530
+ def query_timeout=(value)
531
+ number = value.is_a?(String) ? Integer(value, exception: false) || Float(value, exception: false) : value
532
+ unless number.is_a?(Numeric) && number.real? && number.positive? && number.finite?
533
+ raise ArgumentError, "query_timeout must be positive (got #{value})"
534
+ end
535
+
536
+ @query_timeout = number
537
+ end
538
+
532
539
  def query_row_limit=(value)
533
540
  value = value.to_i
534
541
  raise ArgumentError, "query_row_limit must be between 1 and 1000 (got #{value})" unless value.between?(1, 1000)
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module DatabaseFamily
5
+ # MySQL bounds a statement with the session max_execution_time, which also holds a WITH or EXPLAIN ANALYZE.
6
+ # MariaDB has no max_execution_time; its session max_statement_time does the same.
7
+ module MysqlRuntime
8
+ module_function
9
+
10
+ IN_TRANSACTION = "MySQL cannot make a transaction that is already open read-only, so the statement " \
11
+ "was not sent. Run it outside the transaction."
12
+
13
+ def read_only(conn, sql, timeout)
14
+ raise Unsupported, IN_TRANSACTION if conn.transaction_open?
15
+
16
+ result = bounded(conn, timeout) { |session| in_read_only_transaction(conn, sql, session) }
17
+ Rows.new(result.columns, result.rows, false)
18
+ end
19
+
20
+ REPLACED = "the connection was replaced before the statement was sent, and the new session has no " \
21
+ "read-only transaction and no time limit, so the statement was not sent"
22
+
23
+ # The statement limit does not cover SHOW, so lock_wait_timeout (whole seconds) ends a wait on a metadata lock.
24
+ # Both session values are put back after the block, which is given the id of the session that holds them.
25
+ def bounded(conn, timeout)
26
+ limit, value = if conn.mariadb? then [ "max_statement_time", [ timeout.to_f, 0.001 ].max ]
27
+ else [ "max_execution_time", [ (timeout * 1000).to_i, 1 ].max ]
28
+ end
29
+ DatabaseFamily.bounding do
30
+ previous = conn.select_rows("SELECT @@SESSION.#{limit}, @@SESSION.lock_wait_timeout, CONNECTION_ID()").first
31
+ begin
32
+ conn.execute("SET SESSION #{limit} = #{value}, lock_wait_timeout = #{[ timeout.ceil, 1 ].max}")
33
+ yield previous[2]
34
+ ensure
35
+ DatabaseFamily.restore(conn, "SET SESSION #{limit} = #{previous[0]}, lock_wait_timeout = #{previous[1]}")
36
+ end
37
+ end
38
+ end
39
+
40
+ # MySQL rejects SET TRANSACTION once a transaction is open, and Rails opens the lazy BEGIN
41
+ # before the first statement in the block. Unscoped, it applies to the next transaction only.
42
+ def in_read_only_transaction(conn, sql, session)
43
+ result = nil
44
+ conn.execute("SET TRANSACTION READ ONLY")
45
+ conn.transaction do
46
+ # Rails replaces a connection it lost before BEGIN, and a new session has neither guard.
47
+ raise ActiveRecord::ConnectionNotEstablished, REPLACED unless conn.select_value("SELECT CONNECTION_ID()").to_i == session.to_i
48
+
49
+ result = conn.select_all(sql)
50
+ raise ActiveRecord::Rollback
51
+ end
52
+ result
53
+ end
54
+
55
+ def explain(conn, sql, timeout)
56
+ # MySQL 8.3+ defaults to TREE output, which has no table/type/key columns. MariaDB has no FORMAT=TRADITIONAL.
57
+ prefix = conn.mariadb? ? "EXPLAIN" : "EXPLAIN FORMAT=TRADITIONAL"
58
+ result = read_only(conn, "#{prefix} #{sql}", timeout)
59
+ scans = []
60
+ warnings = []
61
+ raw_lines = result.rows.map do |row|
62
+ cols = result.columns.zip(row).to_h
63
+ table = cols["table"]
64
+ rows = cols["rows"].to_i
65
+ extra = cols["Extra"].to_s
66
+ # A row with no table ("No tables used") scans nothing.
67
+ if table
68
+ scans << { table: table, type: scan_label(cols["type"]), index: cols["key"] }
69
+ warnings << "Full table scan on #{table} (#{rows} rows)" if cols["type"] == "ALL" && rows > 1000
70
+ warnings << "Using filesort on #{table}" if extra.include?("filesort")
71
+ warnings << "Using temporary table on #{table}" if extra.include?("temporary")
72
+ end
73
+ cols.map { |k, v| "#{k}: #{v}" }.join(", ")
74
+ end
75
+ { scan_types: scans, warnings: warnings, raw: raw_lines.join("\n"), unbounded: false }
76
+ end
77
+
78
+ def scan_label(type)
79
+ case type
80
+ when "ALL" then "full table scan"
81
+ when "index" then "full index scan"
82
+ when "range" then "index range scan"
83
+ when "ref", "eq_ref" then "index lookup"
84
+ when "const", "system" then "constant lookup"
85
+ else type.to_s
86
+ end
87
+ end
88
+
89
+ def column_sources(_conn, _sql, _plan)
90
+ []
91
+ end
92
+
93
+ def plan(_conn, _sql, _timeout)
94
+ nil
95
+ end
96
+
97
+ # The schemas no secret column rule reads: their relations are refused by name or hold no app data.
98
+ SYSTEM_SCHEMAS = %w[mysql sys performance_schema information_schema].freeze
99
+
100
+ def unqualified_schemas(_conn)
101
+ []
102
+ end
103
+
104
+ # Compared by the server, so a name matches a schema as a statement's own reference would.
105
+ def schemas_named(conn, names)
106
+ conn.select_values(
107
+ "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (#{quoted(conn, names)}) " \
108
+ "AND SCHEMA_NAME NOT IN (#{quoted(conn, SYSTEM_SCHEMAS)}) AND SCHEMA_NAME <> DATABASE()"
109
+ )
110
+ end
111
+
112
+ def quoted(conn, names)
113
+ names.map { |name| conn.quote(name) }.join(", ")
114
+ end
115
+
116
+ def in_schemas(conn, column, schemas)
117
+ schemas.empty? ? "#{column} = DATABASE()" : "#{column} IN (#{quoted(conn, schemas)})"
118
+ end
119
+
120
+ def relation_columns(conn, schemas = [])
121
+ DatabaseFamily.by_table(conn.select_rows(
122
+ "SELECT c.TABLE_NAME, c.COLUMN_NAME FROM information_schema.COLUMNS c JOIN information_schema.TABLES t " \
123
+ "ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME " \
124
+ "WHERE #{in_schemas(conn, "c.TABLE_SCHEMA", schemas)} AND t.TABLE_TYPE = 'BASE TABLE' ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION"
125
+ )).transform_values(&:flatten)
126
+ end
127
+
128
+ # The definition is empty for a view the account may not SHOW.
129
+ def view_definitions(conn, schemas = [])
130
+ DatabaseFamily.by_view(conn.select_rows(
131
+ "SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.VIEWS WHERE #{in_schemas(conn, "TABLE_SCHEMA", schemas)}"
132
+ ))
133
+ end
134
+
135
+ def traced_views(_conn, _schemas = [])
136
+ []
137
+ end
138
+
139
+ def generated_columns(conn, schemas = [])
140
+ DatabaseFamily.keep_expressions({}, conn.select_rows(
141
+ "SELECT TABLE_NAME, COLUMN_NAME, GENERATION_EXPRESSION FROM information_schema.COLUMNS " \
142
+ "WHERE #{in_schemas(conn, "TABLE_SCHEMA", schemas)} AND GENERATION_EXPRESSION <> ''"
143
+ ))
144
+ end
145
+
146
+ def index_tables(_conn, _schemas = [])
147
+ {}
148
+ end
149
+
150
+ # Base tables only: a view has no rows or bytes of its own.
151
+ BASE_TABLES = "FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'"
152
+
153
+ def table_sizes(conn)
154
+ conn.select_all("SELECT TABLE_NAME AS name, (DATA_LENGTH + INDEX_LENGTH) AS bytes #{BASE_TABLES}")
155
+ .map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
156
+ .sort_by { |r| [ -r[:bytes], r[:name] ] }
157
+ end
158
+
159
+ def row_estimates(conn)
160
+ conn.select_all("SELECT TABLE_NAME AS table_name, TABLE_ROWS AS approximate_row_count #{BASE_TABLES}")
161
+ .map { |r| { table: r["table_name"], approximate_rows: r["approximate_row_count"].to_i } }
162
+ .sort_by { |t| [ -t[:approximate_rows], t[:table] ] }
163
+ end
164
+
165
+ def index_usage(_conn)
166
+ nil
167
+ end
168
+ end
169
+ end
170
+ end