Class: RailsAiBridge::Tools::Query

Inherits:
BaseTool
  • Object
show all
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

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).

Parameters:

  • sql (String) —

    a single plain SELECT statement

  • row_limit (Integer, nil) (defaults to: nil) —

    max rows (hard-capped at MAX_ROWS)

  • detail (String) (defaults to: 'standard') —

    summary, standard, or full

Returns:

  • (MCP::Tool::Response) —

    JSON payload with columns/rows, or "..."

Raises:

  • (Timeout::Error) —

    never escapes; converted to an error response



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.

Parameters:

  • message (String) —

    error message

Returns:

  • (MCP::Tool::Response)


163
164
165
# File 'lib/rails_ai_bridge/tools/query.rb', line 163

def self.error_response(message)
  text_response({ error: message }.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.

Parameters:

  • sql (String) —

    validated SELECT statement

  • row_limit (Integer, nil) —

    requested row limit

  • detail (String) —

    detail level

Returns:

  • (MCP::Tool::Response) —

    compact JSON payload or message



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.

Parameters:

  • error (StandardError) —

    raised error

Returns:

  • (MCP::Tool::Response) —

    sanitized error payload



177
178
179
# File 'lib/rails_ai_bridge/tools/query.rb', line 177

def self.execution_failure(error)
  error_response(Registry::MessageSanitizer.sanitize(error.message))
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.

Parameters:

  • columns (Array<String>) —

    result column names

  • rows (Array<Hash>) —

    result rows

Returns:

  • (Array<Hash>) —

    rows with credential-like columns redacted



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].

Parameters:

  • row (Hash) —

    result row keyed by column name

  • columns (Array<String>) —

    credential-like column names

Returns:

  • (Hash) —

    row with the given columns 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.

Parameters:

  • sql (String) —

    validated SELECT statement

Returns:

  • (ActiveRecord::Result, MCP::Tool::Response) —

    query result or timeout error



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.

Returns:

  • (MCP::Tool::Response) —

    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.

Yields:

  • the statement execution

Returns:

  • (Object) —

    the block's result

Raises:

  • (Timeout::Error) —

    when execution exceeds TIMEOUT_SECONDS



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