rails ai-agents

Ruby MCP Server: Build One and Connect It to Claude

14 min read

Build a Model Context Protocol server in Ruby and Rails with the official mcp gem or fast-mcp, then connect its tools to Claude Desktop and Claude Code.

The Model Context Protocol is how you let Claude call your code without writing the client-side glue. This is the practical guide to building an MCP server in Ruby, wiring it to real Rails data, and connecting it to Claude Desktop and Claude Code, using only libraries that actually exist and their current API.

Ruby MCP server architecture - a Rails app exposing ActiveRecord-backed tools over the Model Context Protocol to Claude Desktop and Claude Code

I covered the MCP connector briefly in my guide to building AI agents in Ruby with the Anthropic SDK. That post was about the client: an agent that consumes tools. This one is the other side of the wire. Here you are the server, publishing tools that any MCP host can discover and call.

What MCP Is

MCP is a standard protocol that lets an AI application discover and call tools exposed by a separate program. A server advertises tools (functions the model can invoke), resources (read-only context like a file or a database record), and prompts (reusable templates), all over JSON-RPC 2.0. A client (the MCP host, such as Claude Desktop, Claude Code, or Cursor) connects, lists what the server offers, and calls it on the model's behalf. The transport is either stdio (a local process, one client) or Streamable HTTP (a remote server, many clients). The point of the standard is that you write your tool once and every MCP-capable client can use it, instead of building a bespoke integration per host.

Ruby MCP Libraries: What's Actually Available

Three gems cover Ruby MCP, and they barely overlap: the official mcp gem for a protocol-complete server, fast-mcp for Rails ergonomics, and ruby_llm-mcp for the client side. The ecosystem is real but young, so pin your versions and read changelogs before upgrading.

Gem Maintainer Status Transports Use it when
mcp (official SDK) modelcontextprotocol org, with Shopify Actively maintained, pre-1.0 (v0.22 as of writing) stdio, Streamable HTTP You want the reference server: protocol-complete and framework-agnostic
fast-mcp yjacquin (community) Actively maintained stdio, HTTP, SSE (Rack/Rails) You want Rails ergonomics: a generator, a mountable endpoint, dry-schema validation
ruby_llm-mcp Patrick Vice (community) Actively maintained stdio, Streamable HTTP, SSE You are the client: calling MCP servers from Ruby via RubyLLM
model-context-protocol-rb dickdavis (community) Earlier stage, smaller community stdio, HTTP You want an alternative server implementation to evaluate

My default is the official mcp gem for a standalone server, fast-mcp when the server lives inside a Rails app and I want the mountable endpoint and validation, and ruby_llm-mcp when the Ruby app is the one calling out to MCP servers. The two server gems are not competitors so much as different ergonomics over the same protocol. Everything below uses the official gem for the minimal build and fast-mcp for the Rails example, because those are the two I would actually reach for.

Build a Minimal MCP Server in Ruby

Build a minimal MCP server in Ruby with the official mcp gem in three steps: subclass MCP::Tool to define a tool, pass it to MCP::Server, and run the server over stdio. The gem gives you the server, the tool base class, and both transports.

# Gemfile
gem "mcp"

A tool is a subclass of MCP::Tool. You give it a description, a typed input schema, and a self.call method that returns a response. The description is not decoration: it is what the model reads to decide whether to call the tool, so write it like a docstring for a competent stranger.

# lib/mcp/tools/word_count_tool.rb
class WordCountTool < MCP::Tool
  description <<~TEXT
    Count the words in a block of text. Use this when the user asks how long
    a document, message, or draft is. Whitespace-separated tokens only.
  TEXT

  input_schema(
    properties: {
      text: { type: "string", description: "The text to count words in" }
    },
    required: ["text"]
  )

  # Note the class method and the server_context keyword the SDK passes in.
  def self.call(text:, server_context:)
    count = text.split(/\s+/).reject(&:empty?).size
    MCP::Tool::Response.new([{ type: "text", text: "#{count} words" }])
  end
end

Two details matter here. The tool is a class method (self.call), not an instance method, and the SDK hands it a server_context keyword you can use to thread per-connection state. The return value is an MCP::Tool::Response wrapping an array of content blocks, each with a type and a payload, the exact shape the protocol defines for a tools/call result.

Now create the server and run it over stdio. The stdio transport is a small executable that talks JSON-RPC over standard input and output.

#!/usr/bin/env ruby
# bin/mcp_server  (chmod +x this file)
require "bundler/setup"   # load gems from the app's Gemfile
require "mcp"
require_relative "../lib/mcp/tools/word_count_tool"

server = MCP::Server.new(
  name: "text_tools",
  version: "1.0.0",
  tools: [WordCountTool]
)

transport = MCP::Server::Transports::StdioTransport.new(server)
transport.open

The gotcha on stdio: stdout is the protocol channel. Anything you puts corrupts the JSON-RPC stream and the client silently drops the connection. Send every log line to $stderr (or a file) instead. If your server "connects but has no tools," this is the first thing to check.

For a remote server, swap the transport. The official gem ships MCP::Server::Transports::StreamableHTTPTransport, which you mount as a Rack endpoint (mount transport => "/mcp" in a Rails routes file). Same server, same tools, different wire.

Exposing Rails Functionality as MCP Tools

Inside a Rails app, fast-mcp is the more natural fit. It ships a generator, mounts an HTTP and SSE endpoint into your existing routes, validates arguments with dry-schema, and auto-registers tool classes. Install it and run the generator:

# Gemfile
gem "fast-mcp"
bin/rails generate fast_mcp:install

That creates config/initializers/fast_mcp.rb and an app/tools directory. The initializer mounts the server and registers your tools:

# config/initializers/fast_mcp.rb
FastMcp.mount_in_rails(
  Rails.application,
  name: "acme-api",
  version: "1.0.0",
  path_prefix: "/mcp",
  messages_route: "messages",
  sse_route: "sse"
) do |server|
  server.register_tools(*ApplicationTool.descendants)
end

A tool is a class with a description, a dry-schema arguments block, and a call method. Because it is plain Ruby running inside your app, it has your models, so an ActiveRecord-backed tool is just a scoped query returning a narrow projection:

# app/tools/open_invoices_tool.rb
class OpenInvoicesTool < ActionTool::Base   # fast-mcp's Rails-flavored FastMcp::Tool
  description "List open invoices for one customer, newest first."

  arguments do
    required(:customer_id).filled(:integer).description("The customer's ID")
    optional(:limit).filled(:integer).description("Max rows to return, default 20")
  end

  def call(customer_id:, limit: 20)
    # Return only the columns the model needs. Every extra field is tokens
    # you pay for and context you may not want the model to see.
    Invoice.where(customer_id: customer_id, status: :open)
           .order(created_at: :desc)
           .limit(limit)
           .as_json(only: %i[id number amount_cents due_on])
  end
end

Now the authorization caveat, because it is the part people get wrong. An MCP tool has no session, no current_user, and no Pundit context unless you put it there. A stdio server runs as whatever user launched the process, with full database access. An HTTP-mounted server answers whoever can reach the endpoint. Neither one inherits the request-scoped authorization your controllers rely on.

So the query above is dangerous as written: it will return any customer's invoices to anyone who can call the tool. In a real deployment you scope it. On the HTTP transport, authenticate the connection (fast-mcp supports a token via authenticate: true and auth_token:), resolve that token to a tenant or user, and scope every query through your existing policies exactly as you would in a controller. The tool is a thin adapter; the authorization stays in the domain where it is already tested. I go deeper on this in the AI agents guide's authorization section, and the same rule holds here: a tool must never be able to read more than the identity it acts for.

Connecting the Server to Claude and Claude Code

For a local stdio server, Claude Desktop reads claude_desktop_config.json. On macOS it lives at ~/Library/Application Support/Claude/claude_desktop_config.json. Point it at your runner script:

{
  "mcpServers": {
    "text-tools": {
      "command": "/Users/you/app/bin/mcp_server",
      "env": {
        "BUNDLE_GEMFILE": "/Users/you/app/Gemfile"
      }
    }
  }
}

The command must be an absolute path, and BUNDLE_GEMFILE lets require "bundler/setup" find your app's gems when Claude launches the process outside your shell. Restart Claude Desktop completely; on relaunch the server indicator shows your tools. If it does not appear, tail ~/Library/Logs/Claude/mcp*.log.

Claude Code uses the CLI. The -- separates Claude's own flags from the command that starts your server:

# Local stdio server
claude mcp add --env BUNDLE_GEMFILE=/Users/you/app/Gemfile \
  text-tools -- /Users/you/app/bin/mcp_server

# Remote HTTP server (the fast-mcp endpoint mounted at /mcp)
claude mcp add --transport http acme-api http://localhost:3000/mcp

Add --scope project to write the config into a shared .mcp.json at the repo root so your team gets the same server, or --scope user to make it available across all your projects. Verify with claude mcp list.

The Client Side: Calling MCP Servers from Ruby

Yes, Ruby has an MCP client, not just servers: the ruby_llm-mcp gem. It connects your own code to an MCP server over stdio, SSE, or Streamable HTTP and hands the server's advertised tools to a model through RubyLLM. Reach for it when the Ruby app is the caller rather than the server.

# Gemfile
gem "ruby_llm-mcp"
# Connect to a local stdio MCP server
client = RubyLLM::MCP.client(
  name: "filesystem",
  transport_type: :stdio,
  config: {
    command: "npx",
    args: ["@modelcontextprotocol/server-filesystem", "/path/to/project"]
  }
)

# Hand the server's tools to a chat and let the model call them.
chat = RubyLLM.chat(model: "claude-sonnet-5")
chat.with_tools(*client.tools)

response = chat.ask("Summarize the README in this project.")

client.tools fetches the server's advertised tools and wraps them as RubyLLM tools, so with_tools treats them like any native tool and RubyLLM runs the call loop. For a remote server, switch the transport:

client = RubyLLM::MCP.client(
  name: "internal-api",
  transport_type: :sse,
  config: { url: "https://mcp.internal.example.com/sse" }
)

This is worth contrasting with the Anthropic API's server-side MCP connector, which I covered in the Anthropic Ruby SDK deep dive. With the connector, Anthropic's infrastructure connects to a public MCP endpoint for you and you write no client loop at all. With ruby_llm-mcp, the connection is made from your process, so a server that must stay inside your network never has to be exposed to the internet. That single difference, where the connection originates, is usually what decides between the two.

Limitations and Security

Ruby MCP has five limitations to weigh before you ship: the libraries are pre-1.0 and still moving, Streamable HTTP auth (OAuth in particular) is still settling in the Ruby gems, the protocol has no built-in per-user authorization, every tool's output is untrusted input, and write tools can do damage at machine speed. Each is manageable, none is optional.

The libraries are young. The official gem is pre-1.0 and its API still moves between minor versions; fast-mcp and ruby_llm-mcp are community projects under active change. Pin exact versions and read changelogs before upgrading. Code that works today can break on a bundle update.

Transport maturity is uneven. Stdio is simple and solid. Streamable HTTP is newer, and the authorization story around it (OAuth in particular) is still settling in the Ruby gems. If you can solve your problem with a local stdio server, that is the lower-risk path.

An MCP server is an open door until you lock it. The protocol has no built-in per-user authorization. A stdio server runs with the launching user's full privileges; an HTTP server serves anyone who can reach it. You are responsible for authenticating the connection and scoping every tool to the identity behind it. Mounting MCP inside your production Rails app means an LLM client is one hop from ActiveRecord, so scope hard and start read-only.

Tool output is untrusted input. Anything a tool returns (a database text field, a fetched web page, a filename) can carry instructions aimed at the model. This is prompt injection, and it applies to your server's output the same as any other tool result. The defenses from the agents guide apply without exception: treat retrieved content as data, never as instructions, and be extremely conservative about tools that write.

Write tools deserve friction. A tool that reads cannot delete your data; a tool that writes can do so at machine speed and machine confidence. Keep write actions behind explicit authorization, narrow scopes, and ideally a human confirmation step for anything irreversible.

Where to Start

If you just want to see it work, build the stdio word-count server with the official gem and wire it into Claude Desktop in an afternoon; the loop from "tool class" to "Claude calls it" is short and clarifies the whole model. Once that clicks, decide which side of the wire you are on. Exposing your Rails app's data to AI clients points you at fast-mcp and its mountable endpoint, with authorization as the first thing you build, not the last. Consuming other servers from Ruby points you at ruby_llm-mcp. Either way, the protocol is the easy part. The engineering that matters is the same as always: narrow tools, tight scoping, and treating everything that crosses the boundary as untrusted.

Need help exposing a Rails app to AI clients safely, or wiring MCP tools into a product? I work with teams on MCP servers, tool design, and the authorization and observability that make them safe to ship. Reach out at hello at nsinenko.com.

Further Reading