Building an MCP Server Inside Your Rails App
Building an MCP Server Inside Your Rails App
You've read the explainers. You know the Model Context Protocol (MCP) is the open standard that lets AI assistants such as Claude, ChatGPT, Cursor and many IDE agents call into external systems through a shared interface. You know it differs from RAG: RAG pulls text into a prompt, while MCP lets the model act by calling tools, reading resources and using prompts you define.
So let's build one. Your Rails app most likely already holds the useful data and business logic, so it makes sense to put the MCP server inside the app instead of in a separate service. You keep your models, your authorization rules and your deploy pipeline. This guide uses the official Ruby SDK, the mcp gem, maintained under the modelcontextprotocol GitHub organization.
What we're building
Our example is a small order management app. An assistant connected to it should be able to:
- Search a customer's orders (read only).
- Look up one order in detail (read only).
- Add an internal note to an order (a write action, kept small on purpose).
Each request is scoped to the authenticated user, so the assistant only sees what that user is allowed to see.
Step 1: Install the gem
# Gemfile
gem "mcp"
bundle install
That's the only dependency. The gem handles JSON-RPC message parsing, the protocol lifecycle (initialize, tools/list, tools/call and so on) and the Streamable HTTP transport.
Step 2: Define your tools
In the Ruby SDK, a tool is a class that inherits from MCP::Tool. You give it a description, a JSON Schema for its input and a self.call method. By default the SDK names the tool after the class, without its namespace and in snake_case, so Tools::SearchOrders becomes search_orders. Put the tools somewhere Zeitwerk will autoload them, such as app/mcp/tools/.
# app/mcp/tools/search_orders.rb
module Tools
class SearchOrders < MCP::Tool
title "Search orders"
description "Search the current user's orders by status or customer email. " \
"Returns up to 20 orders, newest first."
input_schema(
properties: {
status: { type: "string", enum: %w[pending paid shipped refunded] },
customer_email: { type: "string" }
}
)
annotations(read_only_hint: true, destructive_hint: false)
def self.call(server_context:, status: nil, customer_email: nil)
user = server_context[:user]
scope = user.accessible_orders.order(created_at: :desc).limit(20)
scope = scope.where(status: status) if status
scope = scope.joins(:customer).where(customers: { email: customer_email }) if customer_email
rows = scope.map { |o| "##{o.number} · #{o.status} · #{o.total.format} · #{o.created_at.to_date}" }
text = rows.any? ? rows.join("\n") : "No matching orders."
MCP::Tool::Response.new([{ type: "text", text: text }])
end
end
end
Some points about this example:
- Write the description for the model. The LLM decides when to call your tool mostly from its name and description. Say what the tool does, what it returns and any limits it has. A vague description ("Gets orders") leads to bad tool selection.
- Keyword arguments map to schema properties. The SDK passes the arguments as keywords with symbol keys, plus
server_context:. - Annotations are hints.
read_only_hintanddestructive_hinttell clients what a tool does, and a client can use them to decide when to ask the user for confirmation. They aren't enforced, and the spec tells clients to treat them as untrusted unless the server is trusted. Note that the SDK defaultsdestructive_hinttotrueandread_only_hinttofalse, so state them explicitly. - Scope through the user.
user.accessible_ordersis your existing authorization logic. Never queryOrder.allin a tool.
The detail lookup works the same way, and it shows how to return errors:
# app/mcp/tools/get_order.rb
module Tools
class GetOrder < MCP::Tool
description "Fetch full details for one order by its order number, including line items and notes."
input_schema(
properties: { number: { type: "string", description: "Order number, e.g. 10423" } },
required: ["number"]
)
annotations(read_only_hint: true)
def self.call(number:, server_context:)
order = server_context[:user].accessible_orders.find_by(number: number)
unless order
return MCP::Tool::Response.new(
[{ type: "text", text: "Order #{number} not found or not accessible." }],
error: true
)
end
MCP::Tool::Response.new([{ type: "text", text: OrderPresenter.new(order).to_llm_text }])
end
end
end
Use error: true for failures the model can recover from. It sets isError on the tool result, so the model sees the message and can try again with different input. If you raise an exception instead, the SDK returns a JSON-RPC internal error, which is the wrong signal for "that order doesn't exist."
The OrderPresenter#to_llm_text method is worth having. Raw to_json dumps of ActiveRecord objects waste tokens and expose columns the model doesn't need. A plain, labeled text summary usually works better.
The write tool:
# app/mcp/tools/add_order_note.rb
module Tools
class AddOrderNote < MCP::Tool
description "Append an internal (staff only) note to an order. Does not notify the customer."
input_schema(
properties: {
number: { type: "string" },
body: { type: "string", maxLength: 2000 }
},
required: %w[number body]
)
annotations(read_only_hint: false, destructive_hint: false, idempotent_hint: false)
def self.call(number:, body:, server_context:)
user = server_context[:user]
order = user.accessible_orders.find_by(number: number)
return MCP::Tool::Response.new([{ type: "text", text: "Order not found." }], error: true) unless order
order.notes.create!(body: body, author: user, source: "mcp")
MCP::Tool::Response.new([{ type: "text", text: "Note added to order ##{number}." }])
end
end
end
Tagging the note with source: "mcp" gives you an audit trail of what the AI did, which is useful later.
Step 3: Expose the server over HTTP
The SDK supports stdio, which suits local command line tools, and Streamable HTTP, which is what a web app needs. You can mount the transport directly in your routes, but a controller gives you control over authentication and per request context, so that's what we'll use:
# app/controllers/mcp_controller.rb
class McpController < ActionController::API
before_action :authenticate_token!
def create
server = MCP::Server.new(
name: "orders",
title: "Acme Orders",
version: "1.0.0",
instructions: "Use these tools to look up and annotate Acme customer orders.",
tools: [Tools::SearchOrders, Tools::GetOrder, Tools::AddOrderNote],
server_context: { user: @current_user }
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
status, headers, body = transport.handle_request(request)
render(json: body.first, status: status, headers: headers)
end
private
def authenticate_token!
token = request.authorization.to_s.delete_prefix("Bearer ")
@current_user = ApiToken.active.find_by(token_digest: ApiToken.digest(token))&.user
head :unauthorized unless @current_user
end
end
# config/routes.rb
post "/mcp", to: "mcp#create"
Two choices in this controller matter:
Stateless mode. stateless: true means the transport doesn't keep MCP sessions in memory between requests. That's what you want behind a load balancer with several Puma workers or nodes, since any request can go to any process. You lose server initiated messages such as notifications and server to client requests, which simple request/response tools don't need anyway.
A new server per request. Building MCP::Server on every request costs little, and it lets you bind server_context to the authenticated user. You could also choose the tool list per user, for example registering AddOrderNote only for staff accounts. If a tool isn't registered, the model can't call it, and that's stronger than any prompt instruction.
For authentication, this example uses a hashed bearer token that each user creates in your settings page. That's the quickest way to get started. The MCP spec also defines an authorization flow based on OAuth 2.1, which hosted clients increasingly expect. If you already run Doorkeeper, you can add it later without changing your tools.
Step 4: Test it
Start with the MCP Inspector, the protocol's official debugging UI:
npx @modelcontextprotocol/inspector
Choose the Streamable HTTP transport, enter http://localhost:3000/mcp, add your Authorization header and connect. You should see your three tools with their schemas, and you can call each one by hand.
Then connect a real assistant. In Claude Code, for example:
claude mcp add --transport http acme-orders http://localhost:3000/mcp \
--header "Authorization: Bearer YOUR_TOKEN"
Ask "What did customer jane@example.com order last month?" and watch the model choose search_orders, then get_order.
For automated tests, call the tool classes directly. They're plain Ruby:
test "search is scoped to the user" do
response = Tools::SearchOrders.call(server_context: { user: users(:alice) })
refute_includes response.content.first[:text], orders(:bobs_order).number
end
Add one request spec against /mcp that sends a JSON-RPC tools/list call, and you'll know the full pipeline works.
Production checklist
Before you ship, check the following:
- Rate limiting. Agents can call tools in tight loops. Rails' built in
rate_limit(Rails 7.2 and later) or Rack::Attack on/mcphandles this. - Output size. Paginate and truncate. A tool that returns 5,000 rows uses up the model's context window and your token budget.
- Logging. Log the tool name, arguments and user for every call. You'll use this to debug odd behavior and to answer "what did the AI do?"
- Exception reporting. Set
config.exception_reporterin anMCP.configureblock to send errors to Sentry, Honeybadger or whatever you already use. - Tool design. Start with a few focused, well described tools and add more as needed. A handful of precise tools usually beats one generic
run_querytool, both for model accuracy and for security.
Where to go next
Tools are only one of MCP's building blocks. The same SDK lets you expose resources, which are read only data such as a knowledge base article or a CSV export, and prompts, which are reusable templates such as "draft a refund reply for order X." When your tools work reliably, these are the next things to add.
The main advantage of building inside Rails is reuse. Your models, scopes, policies and tests already describe what your app does and who may do it, and the MCP server exposes that existing logic to AI clients without a separate copy of it.