🛠️tutorialintermediate

Exposing ActiveRecord Models to AI Agents

Learn how to safely let Claude query your Rails database without writing a full API. Transform your User and Product data into AI-accessible tools.

ByMCP Directory Team
Published
⏱️30 minutes
rubyrailsactiverecorddatabasesecurity

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

  1. The Problem: LLMs Don't Know Your Business Data
  2. The Solution: ActiveRecord Meets MCP
  3. Project Setup
  4. Mapping Schema: Rails to JSON Schema
  5. Implementing Search Tools
  6. Critical Security Considerations
  7. Advanced Patterns
  8. Production Deployment
  9. 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

Summary

You've learned how to:

  1. Set up an MCP server that loads your Rails environment
  2. Translate Rails model schemas to JSON Schema
  3. Build safe, parameterized database queries
  4. Return clean JSON that LLMs can understand
  5. Implement proper security measures to protect your data

Your Rails database is now accessible to AI agents—safely and securely.