How to Build an MCP Server for Your Rails App
An MCP (Model Context Protocol) server lets Claude, Cursor, ChatGPT connectors and your own agents call your app through typed tools. If your product is a Rails app, you can build the server right inside it, reusing your models, auth and tenancy rules. Here's the approach I use.
- Add the gem
- Define a tool
- Mount the server in Rails
- Scope every call to a user or account
- Test it locally
- Production checklist
1. Add the gem
The official Ruby SDK is published as the mcp gem (modelcontextprotocol/ruby-sdk). It supports stdio and Streamable HTTP and ships a complete Rails example.
bundle add mcp2. Define a tool
Each tool is a class with a description, a JSON Schema for its inputs and a call method. Keep tools narrow: one clear action, predictable output, helpful errors. Putting them in app/tools lets Zeitwerk autoload them.
# app/tools/find_order_tool.rb
class FindOrderTool < MCP::Tool
description "Look up an order by its number for the current account"
input_schema(
properties: { number: { type: "string" } },
required: ["number"]
)
class << self
def call(number:, server_context:)
account = server_context[:account]
order = account.orders.find_by(number: number)
text = order ? order.to_json(only: %i[number status total_cents]) : "No order #{number}"
MCP::Tool::Response.new([{ type: "text", text: text }])
end
end
end3. Mount the server in Rails
The Streamable HTTP transport is a Rack app, so it mounts like any engine. The SDK's Rails example builds the server in config/routes.rb because tool classes are autoloadable by then but not yet in initializers.
# config/routes.rb
server = MCP::Server.new(
name: "my_app",
version: "1.0.0",
tools: [FindOrderTool]
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
Rails.application.routes.draw do
mount(transport => "/mcp")
endThe default transport keeps session state in memory, so run a single server process or follow the SDK's notes on stateless and multi-instance deployments.
4. Scope every call to a user or account
This is where most of the real work is. The model should never decide whose data it sees. Authenticate the request (OAuth or a per-user token), resolve the current account, and pass it through server_context so every tool queries through that scope, as account.orders does above. In practice that means:
- Read tools first; add write tools one at a time.
- Destructive actions require explicit confirmation or a human approval step.
- Log every tool call with actor, redacted arguments and result.
- Rate-limit per account, and return compact responses instead of whole records.
5. Test it locally
Start Rails and send an initialize request, then tools/list and tools/call. The MCP Inspector or a client like Claude Desktop or Cursor pointed at http://localhost:3000/mcp works too. Add request specs for each tool so schema or scoping regressions fail CI.
curl -s -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'6. Production checklist
- Auth on the
/mcproute (not just the app's session cookie). - Tenant isolation verified by tests, not by prompts.
- Tool descriptions written for the model: when to use it, what it returns.
- Versioning plan for tool changes so existing clients don't break.
- Observability: structured logs and alerts on unusual write volume.
- Review for prompt injection arriving through tool results (e.g. user-entered text).
Want help building yours?
I build MCP servers and AI features for Rails and other stacks. See MCP server development or Ruby on Rails consulting.
Book a Free 30-Min Consult