Building a Remote MCP Server in Ruby on Rails, A Production Guide
Most MCP tutorials are Python or Node.js.
This is the Rails version, built in production, serving real users, powering an AI agent inside a SaaS product.
Here's the architecture, the key decisions, and the gotchas that cost us time.
What MCP Actually Is
Model Context Protocol is a standard for connecting AI models to external tools and data sources.
Before MCP, you'd give Claude a document and ask it questions. After MCP, Claude can call your API, read your database, take actions in your product, all from a conversation.
The protocol is simple: JSON-RPC 2.0. Claude sends requests, your server responds. That's it.
What makes it powerful is the standard — Claude knows how to discover, authenticate with, and call any MCP server that implements the spec correctly.
Local vs Remote, Why This Distinction Matters
Most tutorials show local MCP servers a process running on the user's machine, communicating via stdio.
That works for developer tools. It doesn't work for SaaS.
For a multi-tenant web application, you need a remote MCP server an HTTP endpoint that authenticates each user and serves their specific data.
This changes everything:
| Local (stdio) | Remote (HTTP) | |
|---|---|---|
| Transport | stdin/stdout | HTTP + SSE |
| Auth | None needed | OAuth 2.0 required |
| Multi-user | No | Yes |
| Deployment | User's machine | Your server |
| Use case | Dev tools, CLIs | SaaS products |
Rails is perfect for remote MCP. It's already an HTTP server. It already has auth. You're just adding a new endpoint.
The Architecture
Claude (client)
↓ JSON-RPC 2.0 over HTTPS
POST /mcp/v1/messages
↓
ApplicationController (auth + plan gating)
↓
MCP Tool Handler
↓
Your existing Rails services
No new infrastructure. No separate process. No Node.js. Just a new controller that speaks JSON-RPC.
Step 1 — The Routes
# config/routes.rb
# These must be at root level — outside any scope or namespace
# OAuth Discovery — Claude hits these before anything else
get "/.well-known/oauth-protected-resource", to: "mcp/oauth#protected_resource"
get "/.well-known/oauth-authorization-server", to: "mcp/oauth#authorization_server"
# OAuth Flow
scope "/mcp/oauth" do
post "register", to: "mcp/oauth#register"
get "authorize", to: "mcp/oauth#authorize", as: :mcp_oauth_authorize
post "token", to: "mcp/oauth#token", as: :mcp_oauth_token
end
# MCP Endpoint
post "mcp/v1/messages", to: "mcp/v1/messages#create", defaults: { format: :json }
Critical: The .well-known routes must exist before you test anything with Claude.ai. When a user adds your integration, Claude hits these discovery endpoints first. Without them the connection silently fails and you'll spend a day wondering why.
Step 2 — Authentication
For a SaaS product, you likely already have API keys. Use them.
# app/controllers/mcp/v1/base_controller.rb
module Mcp
module V1
class BaseController < ActionController::API
before_action :authenticate!
private
def authenticate!
# Accept token from header or query param
token = request.headers["Authorization"]
.to_s
.delete_prefix("Bearer ")
.strip
token = params[:api_key].to_s.strip if token.blank?
@api_key = ApiKey.find_by(key: token)
unless @api_key
render json: {
jsonrpc: "2.0",
error: { code: -32_600, message: "Unauthorized" }
}, status: :unauthorized
end
@api_key&.touch(:last_used_at)
@current_company = @api_key&.company
end
end
end
end
Notice: we accept the token from both the Authorization header and a query param. This matters because some MCP clients pass it differently, and it lets users connect via a simple URL with ?api_key=xxx during development.
Step 3 — The JSON-RPC Handler
MCP uses JSON-RPC 2.0. Every request has a method, optional params, and an id. Your response always includes the same id.
# app/controllers/mcp/v1/messages_controller.rb
module Mcp
module V1
class MessagesController < BaseController
def create
body = JSON.parse(request.raw_post)
method = body["method"]
params = body["params"] || {}
id = body["id"]
result = dispatch(method, params)
return head :no_content if result == :notification
render json: { jsonrpc: "2.0", id: id, result: result }
rescue JSON::ParserError
render json: { jsonrpc: "2.0", error: { code: -32_700, message: "Parse error" } },
status: :bad_request
rescue => e
Rails.logger.error("[MCP] #{e.class}: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
render json: { jsonrpc: "2.0", id: id, error: { code: -32_603, message: "Internal error" } }
end
private
def dispatch(method, params)
case method
when "initialize"
{
protocolVersion: "2024-11-05",
serverInfo: { name: "your-app", version: "1.0.0" },
capabilities: { tools: {} },
instructions: system_prompt # injected into Claude's context
}
when "tools/list"
{ tools: [YourMcpTool.schema] }
when "tools/call"
call_tool(params)
when /\Anotifications\//
:notification # no response needed
else
raise "Method not found: #{method}"
end
end
def call_tool(params)
name = params["name"]
arguments = params["arguments"] || {}
result = YourMcpTool.new(@current_company).call(name, arguments)
{ content: [{ type: "text", text: result.to_json }] }
rescue => e
{
content: [{ type: "text", text: { error: e.message }.to_json }],
isError: true
}
end
def system_prompt
# This is injected into Claude's context at connect time.
# Use it to tell Claude how to behave, what order to call things,
# and any domain-specific rules your application has.
# Keep it focused — Claude reads this on every connection.
YOUR_SYSTEM_PROMPT
end
end
end
end
Key point: Errors from tool calls should return isError: true with a message in the content, not an HTTP error status. Claude reads the response body, not the HTTP status code. A descriptive error message in JSON is far more useful than a 500 with no body.
Step 4 — The Tool Design Decision
This is the most important architectural decision you'll make.
Option A — Many small tools:
list_posts, create_post, delete_post,
get_analytics, search_media, generate_image...
Option B — One tool, many actions:
your_app (action: "list_posts" | "create_post" | ...)
We went with Option B. Here's why.
With many tools, Claude has to pick the right tool before starting. With one tool and an action parameter, Claude can reason about what to do mid-conversation without switching tools.
It also makes the integration cleaner from the user's perspective — they see one integration in their Claude settings, not a wall of tools.
The schema looks like this:
def self.schema
{
name: "your_app",
description: "Brief description of what this tool does and when to use it.",
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ACTIONS, # your list of supported actions
description: "The action to perform"
},
# ... your other parameters
},
required: ["action"]
}
}
end
Step 5 — OAuth Discovery
For Claude.ai's "Connect" button to work, you need to implement OAuth discovery.
The good news: you don't need a full OAuth server. You can use OAuth as a thin wrapper around your existing API keys.
# app/controllers/mcp/oauth_controller.rb
module Mcp
class OauthController < ActionController::API
# Tells Claude where your MCP server is
def protected_resource
render json: {
resource: "#{base_url}/mcp/v1/messages",
authorization_servers: [base_url],
bearer_methods_supported: ["header", "query"]
}
end
# Tells Claude how to authenticate
def authorization_server
render json: {
issuer: base_url,
authorization_endpoint: "#{base_url}/mcp/oauth/authorize",
token_endpoint: "#{base_url}/mcp/oauth/token",
registration_endpoint: "#{base_url}/mcp/oauth/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp"]
}
end
# Claude registers itself as a client
def register
render json: {
client_id: "claude_#{SecureRandom.hex(8)}",
client_secret: nil,
grant_types: ["authorization_code"],
token_endpoint_auth_method: "none"
}, status: :created
end
# User sees this page and approves the connection
def authorize
# Require the user to be logged in
unless current_user_from_session
session[:oauth_return_to] = request.fullpath
redirect_to login_path and return
end
# Generate short-lived authorization code
code = SecureRandom.hex(32)
REDIS.setex(
"mcp:oauth:code:#{code}",
5.minutes.to_i,
current_user_from_session.api_key.key
)
# Redirect back to Claude with the code
redirect_to "#{params[:redirect_uri]}?code=#{code}&state=#{params[:state]}",
allow_other_host: true
end
# Claude exchanges code for an access token
def token
key = REDIS.get("mcp:oauth:code:#{params[:code]}")
unless key
render json: { error: "invalid_grant" }, status: :bad_request and return
end
REDIS.del("mcp:oauth:code:#{params[:code]}") # single use
render json: {
access_token: key, # your existing API key becomes the token
token_type: "Bearer",
scope: "mcp"
}
end
private
def base_url
"#{request.protocol}#{request.host_with_port}"
end
def current_user_from_session
# Hook into your existing session auth
# For Devise: User.find_by(id: session["warden.user.user.key"]&.first&.first)
end
end
end
The key insight: your existing API key becomes the OAuth access token. You don't need a new token system. OAuth is just a wrapper around what you already have. The code is a short-lived Redis key that maps to the real API key. Claude exchanges it once, gets the API key, and uses it as a Bearer token forever after.
Step 6 — The System Prompt
This is the part nobody writes about. The instructions field in your initialize response gets injected into Claude's context on every connection.
Use it to teach Claude:
- How to behave in your domain
- What order to call actions in
- Rules specific to your application
- How to talk to users
A few principles we learned:
Tell Claude what to do first. "Always call get_account_status before anything else" — Claude follows this reliably.
Encode your validation rules. If certain actions must happen before others, put it in the system prompt. "Always validate before creating. Never skip this step."
Make it dynamic. We append user-specific context — their preferred language, brand tone, timezone — at connect time. Claude immediately writes in the right voice without the user having to explain anything.
Keep it focused. The system prompt should be under 500 words. Long prompts dilute Claude's attention. Every sentence should earn its place.
Testing
Test with curl before testing with Claude. If curl works, Claude will work.
BASE_URL="https://yourapp.com"
API_KEY="your_api_key"
# 1. Test the MCP handshake
curl -s -X POST "$BASE_URL/mcp/v1/messages" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | jq .
# 2. List your tools
curl -s -X POST "$BASE_URL/mcp/v1/messages" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | jq .
# 3. Call a tool
curl -s -X POST "$BASE_URL/mcp/v1/messages" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_KEY" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "your_app",
"arguments": { "action": "get_account_status" }
}
}' | jq .
# 4. Test OAuth discovery
curl -s "$BASE_URL/.well-known/oauth-protected-resource" | jq .
curl -s "$BASE_URL/.well-known/oauth-authorization-server" | jq .
Run these in order. Each one confirms a layer of the stack is working before you move to the next.
Lessons Learned
The .well-known routes are not optional.
This cost us a full day. Add them first. Test them before anything else.
Errors belong in the response body, not HTTP status codes.
Claude reads JSON. Return { error: "descriptive message" } with isError: true. A 500 with no body tells Claude nothing.
The system prompt matters more than the tool schema.
We spent too long on the schema and not enough on the prompt. Claude's behavior improved more from prompt changes than from schema changes.
Use Redis for lightweight state.
User preferences, session data, short-lived codes — Redis handles all of it without schema migrations. Fast, simple, already in your stack.
Sequential over parallel.
We considered letting Claude batch multiple actions. Don't. Claude reasons better when it processes one response before deciding what to do next.
Test from the user's perspective weekly.
Connect to your own MCP server as a real user and have a real conversation. You'll find issues that unit tests miss — awkward response formats, actions that don't chain well, edge cases in your tool schema.
The Result
A production MCP server in Rails that:
- Authenticates via existing API keys
- Supports OAuth discovery for one-click connection in Claude.ai
- Serves multiple tenants from a single endpoint
- Reuses all existing Rails services
- Deploys exactly like any other Rails endpoint
No new infrastructure. No Node.js. No separate process.
Just Rails doing what Rails does.
Try It
We built this for RobinReach, a social media management platform where users manage their entire social presence through a conversation with Robin, our AI manager.
robinreach.com → Settings → Connect Claude
Questions? Drop a comment. Happy to go deeper on any part of this.
Top comments (0)