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:

  1. Search a customer's orders (read only).
  2. Look up one order in detail (read only).
  3. 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_hint and destructive_hint tell 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 defaults destructive_hint to true and read_only_hint to false, so state them explicitly.
  • Scope through the user. user.accessible_orders is your existing authorization logic. Never query Order.all in 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 /mcp handles 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_reporter in an MCP.configure block 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_query tool, 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.

Need a senior engineer on your side?

Fixed-scope builds, contract engagements, or ongoing support — I take on a limited number of clients.