Exposing ActiveRecord Models to AI Agents
Your database is a goldmine of context. Here's how to safely let an LLM query your User, Product, or any ActiveRecord model without writing a full API.
Table of Contents
- The Problem: LLMs Don't Know Your Business Data
- The Solution: ActiveRecord Meets MCP
- Project Setup
- Mapping Schema: Rails to JSON Schema
- Implementing Search Tools
- Critical Security Considerations
- Advanced Patterns
- Production Deployment
- Complete Example
The Problem: LLMs Don't Know Your Business Data
Large Language Models are incredibly capable, but they have a fundamental limitation: they don't know anything about your specific business data. They can't tell you:
- Who your top customers are
- What products are low on stock
- Which orders are pending fulfillment
- What your sales trends look like
Traditional solutions involve:
- Building full REST/GraphQL APIs (time-consuming)
- Implementing RAG with vector databases (complex)
- Manual copy-paste of data into prompts (tedious)
The MCP Solution: Make your ActiveRecord methods directly callable by Claude. No API needed. Just Ruby.
The Solution: ActiveRecord Meets MCP
With MCP, you can expose specific database queries as "tools" that Claude can call. The LLM describes what it wants, and your Ruby code safely fetches the data.
# Claude can now ask: "Find users who signed up this week"
# Your MCP server translates that to:
User.where("created_at > ?", 1.week.ago).select(:id, :name, :email)
The magic is in the translation layer: Claude sends a structured request, your Ruby code executes a safe query, and returns clean JSON.
Project Setup
Option 1: Standalone MCP Server (Recommended for Starting)
Create a separate Ruby script that loads your Rails environment:
# mcp_server.rb
require_relative 'config/environment' # Load Rails
require 'mcp'
server = MCP::Server.new(name: "rails-data-tools")
# Register your tools here...
server.run
Option 2: Rails Initializer
For tighter integration, add to your Rails app:
# config/initializers/mcp_server.rb
if ENV['MCP_SERVER'] == 'true'
require 'mcp'
Thread.new do
server = MCP::Server.new(name: "rails-app-tools")
# Register tools...
server.run
end
end
Gemfile Addition
# Gemfile
gem 'mcp-sdk'
Then bundle install.
Mapping Schema: Rails to JSON Schema
MCP tools require JSON Schema to describe their inputs. Here's how to translate Rails concepts:
Basic Type Mapping
| Rails/Ruby Type | JSON Schema Type |
|-----------------|------------------|
| String | "string" |
| Integer | "integer" |
| Float/Decimal | "number" |
| Boolean | "boolean" |
| Date/DateTime | "string" with format |
| Array | "array" |
| Hash | "object" |
Example: User Search Schema
# Your Rails model
class User < ApplicationRecord
# id: integer
# name: string
# email: string
# role: string (enum: admin, user, guest)
# created_at: datetime
end
# Corresponding MCP input schema
{
type: "object",
properties: {
query: {
type: "string",
description: "Search term for name or email"
},
role: {
type: "string",
enum: ["admin", "user", "guest"],
description: "Filter by user role"
},
created_after: {
type: "string",
format: "date",
description: "Only return users created after this date (YYYY-MM-DD)"
},
limit: {
type: "integer",
minimum: 1,
maximum: 50,
description: "Maximum results to return (default: 10)"
}
}
}
Implementing Search Tools
Here's a complete implementation of a user search tool:
require_relative 'config/environment'
require 'mcp'
server = MCP::Server.new(name: "rails-data-tools")
server.register_tool(
name: "find_users",
description: "Search for users by email or name. Returns limited results for privacy.",
input_schema: {
type: "object",
properties: {
query: {
type: "string",
description: "The search term to match against name or email"
},
role: {
type: "string",
enum: ["admin", "user", "guest"],
description: "Optional: filter by role"
},
limit: {
type: "integer",
minimum: 1,
maximum: 20,
description: "Max results (default: 5)"
}
},
required: ["query"]
}
) do |input|
# Start with base scope
users = User.all
# Apply search filter
query = "%#{input[:query]}%"
users = users.where("email ILIKE ? OR name ILIKE ?", query, query)
# Apply role filter if provided
users = users.where(role: input[:role]) if input[:role]
# Limit results (security: prevent data dumps)
limit = [input[:limit] || 5, 20].min
users = users.limit(limit)
# Select only safe fields (never expose passwords, tokens, etc.)
users = users.select(:id, :name, :email, :role, :created_at)
# Return clean JSON
{ users: users.as_json, count: users.length }
end
server.run
The .as_json Pattern
Critical: Always use .as_json when returning ActiveRecord objects. LLMs need clean JSON, not Ruby object representations:
# BAD - Returns Ruby object notation
{ users: users }
# => {:users=>#<ActiveRecord::Relation [...]>}
# GOOD - Returns clean JSON
{ users: users.as_json }
# => {:users=>[{"id"=>1,"name"=>"John",...}]}
# BETTER - Explicit field selection
{ users: users.as_json(only: [:id, :name, :email, :created_at]) }
Critical Security Considerations
Never Interpolate Raw Input into SQL
This is the most important rule. Never do this:
# DANGEROUS - SQL Injection vulnerability!
User.where("name = '#{input[:query]}'")
# DANGEROUS - Still vulnerable!
User.where("name = #{input[:query]}")
Always use parameterized queries:
# SAFE - Parameters are escaped
User.where("name ILIKE ?", "%#{input[:query]}%")
# SAFE - Hash syntax
User.where(email: input[:email])
# SAFE - Named parameters
User.where("created_at > :date", date: input[:after_date])
Limit Data Exposure
Never expose sensitive fields:
# BAD - Exposes everything including password_digest, api_keys, etc.
{ user: User.find(id) }
# GOOD - Explicit safe fields only
SAFE_FIELDS = [:id, :name, :email, :created_at, :role]
{ user: User.find(id).as_json(only: SAFE_FIELDS) }
Always Set Result Limits
Prevent data dumps by enforcing maximum results:
server.register_tool(
name: "list_orders",
# ...
) do |input|
# User can request up to 50, but no more
requested_limit = input[:limit] || 10
safe_limit = [requested_limit, 50].min
orders = Order.limit(safe_limit)
# ...
end
Scope Access Appropriately
Consider who's making the request:
server.register_tool(
name: "find_customers",
# ...
) do |input|
# Only return customers from the current organization
# (Assumes you have a way to determine context)
customers = Customer.where(organization_id: current_org_id)
.where("name ILIKE ?", "%#{input[:query]}%")
.limit(10)
{ customers: customers.as_json }
end
Advanced Patterns
Product Search with Inventory
server.register_tool(
name: "search_products",
description: "Search products by name, category, or SKU. Includes inventory levels.",
input_schema: {
type: "object",
properties: {
query: { type: "string" },
category: { type: "string" },
in_stock_only: { type: "boolean" },
min_price: { type: "number" },
max_price: { type: "number" }
}
}
) do |input|
products = Product.includes(:inventory)
# Text search
if input[:query]
query = "%#{input[:query]}%"
products = products.where(
"name ILIKE ? OR sku ILIKE ? OR description ILIKE ?",
query, query, query
)
end
# Category filter
products = products.where(category: input[:category]) if input[:category]
# Stock filter
if input[:in_stock_only]
products = products.joins(:inventory).where("inventories.quantity > 0")
end
# Price range
products = products.where("price >= ?", input[:min_price]) if input[:min_price]
products = products.where("price <= ?", input[:max_price]) if input[:max_price]
products = products.limit(20).select(:id, :name, :sku, :price, :category)
result = products.map do |p|
p.as_json.merge(stock_level: p.inventory&.quantity || 0)
end
{ products: result, count: result.length }
end
Order Lookup with Associations
server.register_tool(
name: "find_order",
description: "Look up an order by ID or order number. Includes line items.",
input_schema: {
type: "object",
properties: {
order_id: { type: "integer" },
order_number: { type: "string" }
}
}
) do |input|
order = if input[:order_id]
Order.find_by(id: input[:order_id])
elsif input[:order_number]
Order.find_by(order_number: input[:order_number])
end
unless order
next { error: "Order not found" }
end
{
order: order.as_json(only: [:id, :order_number, :status, :total, :created_at]),
customer: order.customer.as_json(only: [:id, :name, :email]),
line_items: order.line_items.map do |item|
{
product_name: item.product.name,
quantity: item.quantity,
unit_price: item.unit_price,
subtotal: item.quantity * item.unit_price
}
end
}
end
Aggregation Tools
server.register_tool(
name: "sales_summary",
description: "Get sales summary for a date range",
input_schema: {
type: "object",
properties: {
start_date: { type: "string", format: "date" },
end_date: { type: "string", format: "date" },
group_by: {
type: "string",
enum: ["day", "week", "month"],
description: "How to group results"
}
},
required: ["start_date", "end_date"]
}
) do |input|
start_date = Date.parse(input[:start_date])
end_date = Date.parse(input[:end_date])
orders = Order.where(created_at: start_date..end_date)
.where(status: "completed")
grouping = case input[:group_by]
when "week"
"DATE_TRUNC('week', created_at)"
when "month"
"DATE_TRUNC('month', created_at)"
else
"DATE(created_at)"
end
summary = orders.group(grouping)
.select("#{grouping} as period, COUNT(*) as order_count, SUM(total) as revenue")
.order("period")
{
summary: summary.map do |s|
{
period: s.period.to_s,
order_count: s.order_count,
revenue: s.revenue.to_f.round(2)
}
end,
totals: {
total_orders: orders.count,
total_revenue: orders.sum(:total).to_f.round(2)
}
}
end
Production Deployment
Environment Configuration
# config/mcp_server.rb
require_relative 'config/environment'
require 'mcp'
Rails.application.eager_load! if Rails.env.production?
server = MCP::Server.new(
name: "production-rails-tools",
version: "1.0.0"
)
# Load tools from separate files
Dir[Rails.root.join('lib/mcp_tools/*.rb')].each { |f| require f }
# Register all tools
MCPTools.constants.each do |tool_class|
tool = MCPTools.const_get(tool_class).new
tool.register(server)
end
server.run
Systemd Service (Linux)
# /etc/systemd/system/mcp-rails.service
[Unit]
Description=Rails MCP Server
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp/current
Environment=RAILS_ENV=production
ExecStart=/usr/local/bin/bundle exec ruby mcp_server.rb
Restart=always
[Install]
WantedBy=multi-user.target
Claude Desktop Configuration
{
"mcpServers": {
"rails-data": {
"command": "ssh",
"args": ["deploy@myserver", "cd /var/www/myapp && bundle exec ruby mcp_server.rb"]
}
}
}
Complete Example
Here's a full working example for an e-commerce Rails app:
#!/usr/bin/env ruby
# mcp_server.rb
require_relative 'config/environment'
require 'mcp'
server = MCP::Server.new(name: "ecommerce-tools")
# Safe fields for each model
SAFE_USER_FIELDS = [:id, :name, :email, :role, :created_at]
SAFE_PRODUCT_FIELDS = [:id, :name, :sku, :price, :category, :description]
SAFE_ORDER_FIELDS = [:id, :order_number, :status, :total, :created_at]
# Tool 1: User Search
server.register_tool(
name: "find_users",
description: "Search for users by email or name. Returns limited results.",
input_schema: {
type: "object",
properties: {
query: { type: "string", description: "The search term" }
},
required: ["query"]
}
) do |input|
users = User.where(
"email ILIKE ? OR name ILIKE ?",
"%#{input[:query]}%",
"%#{input[:query]}%"
).limit(5).select(*SAFE_USER_FIELDS)
{ users: users.as_json }
end
# Tool 2: Product Search
server.register_tool(
name: "search_products",
description: "Search products. Can filter by category and stock status.",
input_schema: {
type: "object",
properties: {
query: { type: "string" },
category: { type: "string" },
in_stock: { type: "boolean" }
}
}
) do |input|
products = Product.all
if input[:query]
q = "%#{input[:query]}%"
products = products.where("name ILIKE ? OR sku ILIKE ?", q, q)
end
products = products.where(category: input[:category]) if input[:category]
products = products.where("stock_quantity > 0") if input[:in_stock]
products = products.limit(10).select(*SAFE_PRODUCT_FIELDS)
{ products: products.as_json }
end
# Tool 3: Order Lookup
server.register_tool(
name: "get_order",
description: "Get order details by order number",
input_schema: {
type: "object",
properties: {
order_number: { type: "string" }
},
required: ["order_number"]
}
) do |input|
order = Order.includes(:user, :line_items => :product)
.find_by(order_number: input[:order_number])
unless order
next { error: "Order not found" }
end
{
order: order.as_json(only: SAFE_ORDER_FIELDS),
customer: order.user.as_json(only: [:id, :name, :email]),
items: order.line_items.map do |li|
{
product: li.product.name,
quantity: li.quantity,
price: li.unit_price
}
end
}
end
# Tool 4: Daily Sales Report
server.register_tool(
name: "daily_sales",
description: "Get sales totals for a specific date",
input_schema: {
type: "object",
properties: {
date: {
type: "string",
format: "date",
description: "Date in YYYY-MM-DD format (default: today)"
}
}
}
) do |input|
date = input[:date] ? Date.parse(input[:date]) : Date.today
orders = Order.where(created_at: date.all_day, status: "completed")
{
date: date.to_s,
order_count: orders.count,
total_revenue: orders.sum(:total).to_f.round(2),
average_order_value: orders.average(:total).to_f.round(2)
}
end
puts "Starting Rails MCP Server..."
server.run
Next Steps
- Building Your First MCP Server: Start with the Ruby MCP basics
- Understanding Tools vs. Resources: When to use each primitive
- Testing Your AI Tools with RSpec: Write proper tests without needing Claude
- Stdio vs. SSE Transport: Deploy to production with remote access
Summary
You've learned how to:
- Set up an MCP server that loads your Rails environment
- Translate Rails model schemas to JSON Schema
- Build safe, parameterized database queries
- Return clean JSON that LLMs can understand
- Implement proper security measures to protect your data
Your Rails database is now accessible to AI agents—safely and securely.