Ruby MCP Server: Build a Model Context Protocol Server in Rails for Claude and Cursor
A founder I work with as fractional CTO has a Rails 8 SaaS that manages logistics for mid-sized European retailers. Last month his ops team started asking him to “just let Claude look up shipments in our system.” He could have built another internal chatbot endpoint, wired up function calling, and spent a sprint on it. Instead we shipped a Ruby MCP Server in three afternoons that now plugs straight into Claude Desktop, Cursor, and the Anthropic API on the backend. Same tool surface, three different clients, zero glue code per client.
After nineteen years of Rails I do not get excited about new protocols often. JSON-RPC over stdio is not new. REST with auth is not new. But the Model Context Protocol is the first time I have seen a standard catch on fast enough that it is genuinely worth building against directly. If your Rails app has anything an LLM might want to read or do, you should be exposing it as a Ruby MCP Server. This is the production playbook.
What the Model Context Protocol Actually Is
The Model Context Protocol, MCP for short, is a JSON-RPC 2.0 protocol that lets an LLM client and a tool server talk in a single shared shape. The server advertises capabilities — tools the model can call, resources the model can read, and prompts the model can use — and the client invokes them on the model’s behalf. Anthropic shipped the original spec in late 2024, and by mid-2025 every serious LLM client supported it: Claude Desktop, Claude Code, Cursor, ChatGPT, and most of the agent frameworks.
The point of MCP is not what it does, it is what it replaces. Without MCP, every integration between a model and a system is bespoke. You write a function-calling schema for one client, a custom OpenAPI shim for another, and a plugin manifest for a third. With a Ruby MCP Server you write the integration once, and any compliant client speaks to it. The Rails app stops being “an integration” and becomes a peer in the LLM ecosystem.
Three primitives matter in practice. Tools are typed function calls — give the model a name, a JSON schema for arguments, and a Ruby method that returns a result. Resources are addressable read-only documents — files, database records, or anything else the model might want to load into context. Prompts are reusable templated instructions the user can trigger from the client UI. Most production servers I have built lean heavily on tools, occasionally on resources, and rarely on prompts.
Why a Ruby MCP Server in Rails Makes Sense
The reflex when a Rails team adds AI features is to call out from Rails to the model. The Ruby MCP Server inverts that. The model calls into Rails. Why does that matter? Because most of what the model needs lives behind your authorization layer, your scopes, your audit log, and your domain logic. Recreating that surface in a Python sidecar to feed an LLM is how teams accidentally end up with two source-of-truth systems.
A Ruby MCP Server inside the same Rails app reuses everything. Pundit policies still apply. Current account scoping still applies. Background jobs you already have still work. The MCP layer is a thin adapter — it translates a JSON-RPC tool call into a service object call, and translates the result back. That is the entire job. The hard work — domain logic, validations, authorization — is already in your app.
The other reason this makes sense in 2026 is that the Ruby MCP SDK is finally good. The official mcp gem from the Model Context Protocol working group landed last year, and it handles transports (stdio, SSE, streamable HTTP), capability negotiation, and JSON-RPC framing for you. You write Ruby classes that describe tools and resources, and the SDK does the wire work.
Setting Up a Ruby MCP Server with the SDK
The minimal Ruby MCP Server is about thirty lines. Add the gem, define a tool class, register it, run the server. I will show the stdio transport first because it is the easiest to test against Claude Desktop, and then the streamable HTTP transport for production.
# Gemfile
gem "mcp", "~> 0.5"
# lib/mcp_server/tools/lookup_shipment.rb
module McpServer
module Tools
class LookupShipment < MCP::Tool
description "Look up a shipment by tracking number. Returns status, carrier, and last scan."
input_schema do
required(:tracking_number).filled(:string)
end
def call(tracking_number:, server_context:)
account = server_context.fetch(:account)
shipment = account.shipments.find_by(tracking_number: tracking_number)
return MCP::Tool::Response.new([{ type: "text", text: "Not found" }]) unless shipment
MCP::Tool::Response.new([{
type: "text",
text: ShipmentSerializer.new(shipment).to_json
}])
end
end
end
end
The server_context is the bridge between the protocol and your Rails app. Whatever you put in it on server boot is available inside every tool call. That is where you put the authenticated account, the current user, the request id — everything you would normally read from current_user in a controller.
Bootstrapping the server itself is one file:
# bin/mcp_server
#!/usr/bin/env ruby
require_relative "../config/environment"
server = MCP::Server.new(
name: "ttb-logistics",
version: "1.0.0",
tools: [
McpServer::Tools::LookupShipment,
McpServer::Tools::CreatePickup,
McpServer::Tools::UpdateAddress
],
server_context: { account: Account.find_by!(api_key: ENV.fetch("MCP_ACCOUNT_KEY")) }
)
MCP::Transports::Stdio.new(server).open
Make it executable, point Claude Desktop at it via a config entry, and the model can now call lookup_shipment from inside a chat. Restart the desktop client, ask “what’s the status of TR-12345”, and the tool fires. The total Ruby footprint is the schema, the call method, and a one-line registration.
Exposing Rails Resources via MCP
Tools are the workhorses, but resources are where a Ruby MCP Server starts to feel different from a function-calling endpoint. A resource is anything the model can load into its context as a read. The MCP protocol gives each one a URI, a MIME type, and an optional list. The client decides when to fetch them — sometimes the user picks one explicitly from the UI, sometimes the model asks for it as part of a flow.
In a Rails app, resources are usually views over your records. A customer record. An order. A document. A markdown export of a wiki page. Anything that is read-only and benefits from being loaded into the model’s context.
# lib/mcp_server/resources/customer_resource.rb
module McpServer
module Resources
class CustomerResource < MCP::Resource
uri_template "ttb://customers/{id}"
name "Customer record"
mime_type "application/json"
def list(server_context:)
account = server_context.fetch(:account)
account.customers.order(updated_at: :desc).limit(50).map do |c|
{
uri: "ttb://customers/#{c.id}",
name: c.display_name,
description: "Customer #{c.id} (#{c.tier})"
}
end
end
def read(uri:, server_context:)
account = server_context.fetch(:account)
id = uri.match(%r{ttb://customers/(\d+)})[1]
customer = account.customers.find(id)
[{
uri: uri,
mime_type: "application/json",
text: CustomerSerializer.new(customer).to_json
}]
end
end
end
end
The list method is what populates the resource picker in the client UI. The read method is what fetches one. Both run inside the Rails request lifecycle, both go through Pundit if you want them to, and both can call out to ActiveRecord, Solid Cache, or anything else in your app.
The trick to good resources is to think about what the model would want to load, not what an API would return. A model loading a customer wants the lifetime value, the last three orders, the open tickets, and the account notes inline. Not just the row from the customers table. Treat the serializer as a context-pack, not a JSON dump.
Building Tools That Operate on Your Domain
A read-only Ruby MCP Server is useful. A read-write one is where it gets dangerous. The model is going to call cancel_subscription whenever the user types “cancel my subscription” in chat, and you do not want it to execute against the wrong account, with stale arguments, or without an audit trail.
Three patterns make read-write tools safe. First, scope every tool to the authenticated account. Second, route the tool call through your existing service objects, not a fresh code path. Third, log every tool invocation to an audit table with the input, output, and a correlation id.
module McpServer
module Tools
class CancelSubscription < MCP::Tool
description "Cancel a subscription at the end of the current period."
input_schema do
required(:subscription_id).filled(:string)
optional(:reason).maybe(:string)
end
def call(subscription_id:, reason: nil, server_context:)
account = server_context.fetch(:account)
subscription = account.subscriptions.find(subscription_id)
result = Subscriptions::CancelService.call(
subscription: subscription,
reason: reason,
actor: server_context.fetch(:actor),
source: :mcp
)
AuditLog.create!(
account: account,
action: "mcp.cancel_subscription",
subject: subscription,
payload: { subscription_id: subscription_id, reason: reason },
result: result.success? ? "ok" : "error"
)
if result.success?
MCP::Tool::Response.new([{ type: "text", text: "Cancelled at #{result.cancels_at}" }])
else
MCP::Tool::Response.new([{ type: "text", text: "Error: #{result.error}" }], is_error: true)
end
end
end
end
end
Notice the source: :mcp argument going into the service. That is the audit breadcrumb you want when, six months from now, someone asks why a subscription got cancelled and the only trail is “the assistant did it.” A correlation id from server_context works too. The point is that the model should never be invisible in your data. Every change it makes should be tagged.
This pattern is the same one I wrote about in Rails webhook processing with idempotency — the Ruby MCP Server is just another inbound channel, and it deserves the same idempotency, audit, and authorization rigor as a Stripe webhook.
Authentication and Security for MCP Servers
Stdio is fine for desktop clients running on the same machine as the user. For anything else — a hosted MCP server that Cursor, ChatGPT, or your own backend talks to over the wire — you want streamable HTTP with proper auth. The MCP spec supports OAuth 2.1 with dynamic client registration, but in practice most production servers I see right now use bearer tokens scoped to an account.
Here is the shape of a Rack-mounted Ruby MCP Server inside Rails:
# config/routes.rb
mount McpServer::RackApp.new => "/mcp"
# lib/mcp_server/rack_app.rb
module McpServer
class RackApp
def call(env)
request = Rack::Request.new(env)
token = request.get_header("HTTP_AUTHORIZATION")&.sub(/\ABearer /, "")
account = Account.find_by(mcp_token: token)
return [401, { "content-type" => "text/plain" }, ["unauthorized"]] unless account
Rails.application.executor.wrap do
ActsAsTenant.with_tenant(account) do
server = MCP::Server.new(
name: "ttb-logistics",
version: "1.0.0",
tools: McpServer.tools,
resources: McpServer.resources,
server_context: { account: account, actor: account.mcp_actor }
)
MCP::Transports::StreamableHttp.new(server).call(env)
end
end
end
end
end
A few things matter here. The Rails.application.executor.wrap makes sure connection pooling and reloading work the same as a normal Rails request. The ActsAsTenant.with_tenant gives you database-level scoping if you are running multi-tenant — same defence-in-depth I covered in Pundit authorization for multi-tenant SaaS. And the bearer token is rotatable per account, which means revoking access to your Ruby MCP Server is a single SQL update.
Put the whole thing behind Rack::Attack to throttle abusive clients, and behind a feature flag so you can roll it out account by account. I covered that pattern in Rails feature flags with Flipper — same playbook applies.
Deploying the Ruby MCP Server in Production
There are two deployment shapes. If your Ruby MCP Server is HTTP-based, it deploys exactly like the rest of your Rails app. Same Puma config, same Kamal setup, same observability. The only thing to add is a separate route prefix in your access logs so you can graph MCP traffic separately from human traffic — it has very different latency and concurrency patterns.
If your Ruby MCP Server is stdio-based, you have two options. You can ship a tiny binary that customers run locally (good for desktop integrations), or you can wrap stdio inside a hosted process and expose it over SSE or HTTP at the edge. For most B2B SaaS, hosted HTTP is the answer. Customers do not want to install a binary, and you want every call to land in your observability stack.
A few production lessons. First, set per-tool timeouts inside the tool — do not rely on the client to time out, because some clients will wait forever. Second, return structured errors with is_error: true instead of raising — the model handles structured errors much better than a stack trace in the response. Third, keep tool descriptions short and behavioural. “Cancel a subscription at the end of the current period” beats “Cancels the given subscription using the standard cancellation flow as defined by the billing service.”
The last one matters more than you think. Tool descriptions are what the model uses to decide whether to call your tool. They are prompt engineering, not API documentation. Iterate on them the way you iterate on a system prompt. I usually keep a fixture file with realistic user messages and run them through the client to see which tool the model picks, the same way I would test routing in a controller.
FAQ
What is the difference between a Ruby MCP Server and Claude function calling?
Function calling is a per-request schema you pass to the Anthropic API. A Ruby MCP Server is a long-lived service that any compliant client can connect to. The server-side code is similar — both end up calling a Ruby method with typed arguments — but MCP gives you discovery, lifecycle, and multi-client support out of the box, where function calling requires you to wire each client manually. I covered the function-calling path in LLM function calling in Rails if you want the comparison.
Can I use a Ruby MCP Server with clients other than Claude?
Yes. MCP is a client-neutral standard. Cursor, ChatGPT, Continue, and most of the open-source agent frameworks support it. The same Ruby MCP Server you expose to Claude Desktop can be plugged into Cursor for code-aware tooling, or into a custom Rails-side agent that uses the Anthropic SDK as the model. That is the whole point — write the integration once, get every client.
How do I test a Ruby MCP Server?
The MCP SDK ships with an in-process test harness that lets you call tools and resources from RSpec without going through stdio or HTTP. Use that for unit tests of your tool logic. For integration tests, run the server with the stdio transport and use the official MCP Inspector CLI to drive it. I keep a feature spec that boots the server, lists tools, calls each one with a known input, and asserts the response shape. It catches schema drift fast.
Is a Ruby MCP Server safe to expose to the public internet?
It can be, with the same controls you would put on any API. Bearer auth, per-account rate limiting, audit logging on every tool call, idempotency keys on writes, and feature-flag gating per account. The risk model is the same as a regular API — the difference is that the consumer is an LLM, which means you should assume it will call your tools in unexpected orders with unexpected arguments. Treat tool inputs the way you treat user input. Validate, scope, and log.
Need help shipping a Ruby MCP Server, integrating Claude into your Rails app, or sorting out an AI roadmap that does not turn into shelfware? TTB Software specializes in pragmatic AI integration for Rails teams. We’ve been doing Rails for nineteen years, and AI in Rails since the day Anthropic shipped the SDK.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation