DEV Community

Cover image for Building a Production MCP Server in Ruby on Rails, Lessons from RobinReach
Shaher Shamroukh
Shaher Shamroukh

Posted on

Building a Production MCP Server in Ruby on Rails, Lessons from RobinReach

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

Option B — One tool, many actions:

your_app (action: "list_posts" | "create_post" | ...)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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)