Class: RailsAiBridge::Tools::Query
- Defined in:
- lib/rails_ai_bridge/tools/query.rb,
lib/rails_ai_bridge/tools/query/guard.rb,
lib/rails_ai_bridge/tools/query/payload_formatter.rb
Overview
MCP tool running a single read-only SELECT statement on the host app's established ActiveRecord connection.
Security contract (strictly read-only):
- one statement per call, must start with SELECT (CTEs rejected in v1)
- INSERT/UPDATE/DELETE/ALTER/CREATE/PRAGMA/ATTACH and locking clauses rejected
- hard row limit (MAX_ROWS) applied to returned rows
- wall-clock statement timeout
- values under credential-like columns redacted via Registry::MessageSanitizer
- runs on the app's existing connection; no new connections are opened
Defined Under Namespace
Classes: Guard, PayloadFormatter
Constant Summary collapse
- MAX_ROWS =
Hard upper bound for returned rows regardless of client input.
100- TIMEOUT_SECONDS =
Wall-clock cap for statement execution.
5.0- CREDENTIAL_COLUMN_PATTERN =
Column names matching this pattern are redacted unconditionally.
/(?i)(password|passwd|secret|token|api_?key|auth(?!or(?:$|_)))/- REDACTED =
Placeholder written for redacted column values.
'[redacted]'
Class Method Summary collapse
-
.call(sql:, row_limit: nil, detail: 'standard') ⇒ MCP::Tool::Response
Runs the SELECT statement and returns compact JSON (or an error payload).
-
.error_response(message) ⇒ MCP::Tool::Response
Builds an error response following the message contract.
-
.execute_and_respond(sql, row_limit, detail) ⇒ MCP::Tool::Response
Executes the validated statement and formats the response, converting execution failures into error payloads.
-
.execution_failure(error) ⇒ MCP::Tool::Response
Returns a sanitized execution failure without mutating the application log through this read-only tool.
-
.redact_columns(columns, rows) ⇒ Array<Hash>
Redacts every value under a credential-like column name.
-
.redact_row(row, columns) ⇒ Hash
Replaces the credential-like columns of a single row with [redacted].
-
.run_with_timeout(sql) ⇒ ActiveRecord::Result, MCP::Tool::Response
Runs the statement under the timeout, converting a timeout into an error response.
-
.timeout_error ⇒ MCP::Tool::Response
Statement-timeout error payload.
-
.with_timeout { ... } ⇒ Object
Executes the block under the statement timeout.
Methods inherited from BaseTool
cached_context, cached_section, config, rails_app, reset_cache!, text_response
Class Method Details
.call(sql:, row_limit: nil, detail: 'standard') ⇒ MCP::Tool::Response
Runs the SELECT statement and returns compact JSON (or an error payload).
66 67 68 69 70 71 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 66 def self.call(sql:, row_limit: nil, detail: 'standard') guard_error = Guard.new(sql).error return error_response(guard_error) if guard_error execute_and_respond(sql, row_limit, detail) end |
.error_response(message) ⇒ MCP::Tool::Response
Builds an error response following the message contract.
163 164 165 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 163 def self.error_response() text_response({ error: }.to_json) end |
.execute_and_respond(sql, row_limit, detail) ⇒ MCP::Tool::Response
Executes the validated statement and formats the response, converting execution failures into error payloads.
80 81 82 83 84 85 86 87 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 80 def self.execute_and_respond(sql, row_limit, detail) result = run_with_timeout(sql) return result if result.is_a?(MCP::Tool::Response) text_response(PayloadFormatter.new(result, row_limit: row_limit, detail: detail).format.to_json) rescue StandardError => error execution_failure(error) end |
.execution_failure(error) ⇒ MCP::Tool::Response
Returns a sanitized execution failure without mutating the application log through this read-only tool.
177 178 179 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 177 def self.execution_failure(error) error_response(Registry::MessageSanitizer.sanitize(error.)) end |
.redact_columns(columns, rows) ⇒ Array<Hash>
Redacts every value under a credential-like column name. The column name alone is enough — the value is replaced unconditionally so that opaque or non-secret-looking contents (password_digest, tokens) never leave the process.
143 144 145 146 147 148 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 143 def self.redact_columns(columns, rows) redacted = columns.grep(CREDENTIAL_COLUMN_PATTERN) return rows if redacted.empty? rows.map { |row| redact_row(row, redacted) } end |
.redact_row(row, columns) ⇒ Hash
Replaces the credential-like columns of a single row with [redacted].
155 156 157 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 155 def self.redact_row(row, columns) row.merge(columns.index_with { REDACTED }) end |
.run_with_timeout(sql) ⇒ ActiveRecord::Result, MCP::Tool::Response
Runs the statement under the timeout, converting a timeout into an error response.
94 95 96 97 98 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 94 def self.run_with_timeout(sql) with_timeout { ApplicationRecord.connection.select_all(sql.to_s) } rescue Timeout::Error, ActiveRecord::QueryCanceled timeout_error end |
.timeout_error ⇒ MCP::Tool::Response
Returns statement-timeout error payload.
168 169 170 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 168 def self.timeout_error error_response("Query timed out after #{TIMEOUT_SECONDS} seconds.") end |
.with_timeout { ... } ⇒ Object
Executes the block under the statement timeout.
105 106 107 108 109 110 111 112 |
# File 'lib/rails_ai_bridge/tools/query.rb', line 105 def self.with_timeout(&) connection = ApplicationRecord.connection if postgres_adapter?(connection) with_postgres_timeout(connection, &) else Timeout.timeout(TIMEOUT_SECONDS, &) end end |