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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1024 -0
- data/CONTEXT.md +37 -7
- data/CONTRIBUTING.md +30 -1
- data/README.md +9 -9
- data/SECURITY.md +2 -2
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/CLI.md +44 -15
- data/docs/COMPATIBILITY.md +34 -8
- data/docs/CONFIGURATION.md +28 -5
- data/docs/CUSTOM_TOOLS.md +8 -2
- data/docs/FAQ.md +5 -5
- data/docs/GUIDE.md +33 -65
- data/docs/INTROSPECTORS.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/RECIPES.md +5 -5
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +255 -74
- data/docs/SETUP.md +1 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +151 -90
- data/docs/TROUBLESHOOTING.md +123 -8
- data/docs/_config.yml +1 -1
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
- data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
- data/docs/index.md +2 -2
- data/exe/rails-ai-context +24 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
- data/lib/rails_ai_context/app_databases.rb +377 -0
- data/lib/rails_ai_context/app_kind.rb +2 -0
- data/lib/rails_ai_context/cli/app_root.rb +1 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
- data/lib/rails_ai_context/configuration.rb +15 -8
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
- data/lib/rails_ai_context/database_family.rb +282 -0
- data/lib/rails_ai_context/database_file.rb +61 -7
- data/lib/rails_ai_context/database_yml.rb +7 -29
- data/lib/rails_ai_context/doctor.rb +15 -16
- data/lib/rails_ai_context/instrumentation.rb +1 -1
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
- data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
- data/lib/rails_ai_context/migration_status.rb +18 -46
- data/lib/rails_ai_context/payload.rb +48 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -4
- data/lib/rails_ai_context/run_cache.rb +5 -0
- data/lib/rails_ai_context/schema_adapter.rb +13 -18
- data/lib/rails_ai_context/secret_columns.rb +506 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
- data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
- data/lib/rails_ai_context/server.rb +143 -29
- data/lib/rails_ai_context/sql_gate.rb +1007 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
- data/lib/rails_ai_context/test_helper.rb +3 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
- data/lib/rails_ai_context/tools/base_tool.rb +71 -48
- data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +105 -42
- data/lib/rails_ai_context/tools/generate_test.rb +4 -6
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
- data/lib/rails_ai_context/tools/get_api.rb +1 -6
- data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
- data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
- data/lib/rails_ai_context/tools/get_concern.rb +6 -6
- data/lib/rails_ai_context/tools/get_config.rb +189 -12
- data/lib/rails_ai_context/tools/get_context.rb +111 -105
- data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
- data/lib/rails_ai_context/tools/get_engines.rb +1 -6
- data/lib/rails_ai_context/tools/get_env.rb +1 -6
- data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
- data/lib/rails_ai_context/tools/get_gems.rb +1 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
- data/lib/rails_ai_context/tools/get_schema.rb +10 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
- data/lib/rails_ai_context/tools/onboard.rb +24 -24
- data/lib/rails_ai_context/tools/performance_check.rb +15 -2
- data/lib/rails_ai_context/tools/query.rb +142 -1356
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +6 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
- data/lib/rails_ai_context/tools/safe_call.rb +70 -28
- data/lib/rails_ai_context/tools/search_code.rb +0 -4
- data/lib/rails_ai_context/tools/search_docs.rb +3 -1
- data/lib/rails_ai_context/tools/security_scan.rb +9 -5
- data/lib/rails_ai_context/tools/session_context.rb +18 -12
- data/lib/rails_ai_context/tools/validate.rb +1 -0
- data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +4 -7
- data/server.json +2 -2
- 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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
"#{
|
|
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(
|
|
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
|
-
#
|
|
141
|
-
#
|
|
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.
|
|
154
|
+
tool_class.input_schema&.to_h || {}
|
|
146
155
|
end
|
|
147
156
|
|
|
148
|
-
# The tool a name spells
|
|
149
|
-
#
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 =
|
|
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 =
|
|
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 { |
|
|
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
|
-
|
|
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
|
|
345
|
-
#
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
return nil if properties.empty?
|
|
355
|
+
offered = offered_params
|
|
356
|
+
return nil if offered.empty?
|
|
350
357
|
|
|
351
|
-
required = (
|
|
352
|
-
name =
|
|
353
|
-
"--#{name.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
#
|
|
464
|
-
#
|
|
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
|
|
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
|
|
493
|
-
text = @
|
|
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
|
-
# `
|
|
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
|
-
|
|
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 =
|
|
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
|