19. Query a database from a tool
search_applicants should hit the real database, filter by what the LLM asked for, and return only the columns this user is allowed to see.
Problem. search_applicants should hit the real database, filter by what the LLM asked for, and return only the columns this user is allowed to see.
Solution. You're inside Rails โ use ActiveRecord. Build the query with the relation API (which parameterizes for you), then map the records onto your @rbs type shape explicitly. Gate sensitive columns with @requires on an output variant.
# app/service/workflows/search_applicants.rb
module Workflows
class SearchApplicants
# @rbs import error
include McpAuthorization::DSL
# @rbs type applicant_summary = {
# id: String,
# name: String,
# stage: String
# }
# @rbs type pii_summary = {
# id: String,
# name: String,
# stage: String,
# email: String,
# phone: String
# }
# @rbs type success = { success: true, total: Integer, applicants: Array[applicant_summary] }
# @rbs type pii_success = { success: true, total: Integer, applicants: Array[pii_summary] }
# Gate the whole VARIANT, not a field inside it โ and list it first (see Result).
# @rbs type output = pii_success @requires(:view_pii)
# | success
# | error
def description
"Search applicants by name fragment and/or current stage."
end
#: (
#: ?query: String? @min(1) @max(100) @desc(Case-insensitive name fragment),
#: ?stage: String? @desc(Exact stage to filter by),
#: ?limit: Integer @min(1) @max(100) @default(20)
#: ) -> Hash[Symbol, untyped]
def call(query: nil, stage: nil, limit: 20)
scope = Applicant.all
# Relation methods parameterize the value โ the LLM's text never touches raw SQL.
scope = scope.where("name ILIKE ?", "%#{query}%") if query.present? # ILIKE is PostgreSQL-only; use LIKE on MySQL/SQLite (case-sensitivity varies by collation)
scope = scope.where(stage: stage) if stage.present?
total = scope.count
records = scope.order(updated_at: :desc).limit(limit).to_a
{
success: true,
total: total,
applicants: records.map { |a| summarize(a) }
}
rescue ActiveRecord::StatementInvalid => e
{ success: false, error: { code: "query_failed", message: e.message,
hint: "Check the stage value against list_workflow_stages." } }
end
private
def summarize(applicant)
# id is declared `String` in the type โ to_s it, since projection
# passes values through without coercing them.
base = { id: applicant.id.to_s, name: applicant.name, stage: applicant.stage }
return base unless can?(:view_pii)
base.merge(email: applicant.email, phone: applicant.phone)
end
end
endResult. The query is parameterized, so a query: of "'; DROP TABLE applicants; --" is matched as a literal name fragment, not executed. PII is protected in two layers. The source of truth is summarize: it only adds email/phone when can?(:view_pii). The schema is the backstop โ but only if you gate the right thing. pii_success is a @requires(:view_pii) variant (the tag sits on the union member, not on a field inside the named type โ a field-level tag on a variant resolved by name is honored for nesting but the wrong tool for "show this whole shape to some users"). For a user without the flag, the variant is dropped from the output schema entirely and filter_output projects the return value onto the remaining success shape, stripping email/phone before serialization even if a handler bug let them through. The variant is listed first because success and pii_success share the same top-level keys: when both are visible (a view_pii user), the gem breaks the tie by source order, so the richer PII shape must come first or it gets projected away. Pagination is bounded by @max(100) so the LLM can't ask for a million rows.
Variant: a second database or raw SQL. Reading from an analytics replica or a non-AR datastore? Borrow a pooled connection and sanitize explicitly โ never string-build with LLM input:
def call(account_id:, since:)
rows = ApplicationRecord.connection.exec_query(
ApplicationRecord.sanitize_sql_array(
["SELECT stage, COUNT(*) AS n FROM events WHERE account_id = ? AND created_at >= ? GROUP BY stage",
account_id, since]
)
)
{ success: true, by_stage: rows.map { |r| { stage: r["stage"], count: r["n"] } } }
endThe connection comes from ActiveRecord's pool and is returned automatically at the end of the request โ you don't open or close it yourself.
Collected from COOKBOOK.md in the repository. Edit it there, not here.