Merge pull request #444 from koic/add_rails_example

Add Rails example app
This commit is contained in:
Koichi ITO 2026-07-12 01:43:04 +09:00 • committed by GitHub
commit a1b7b00b22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 275 additions and 0 deletions

View file

@ -180,6 +180,8 @@ end
the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http),
so no additional route configuration is needed.
A complete runnable application using this approach is available in [`examples/rails`](examples/rails).
##### Rails (controller)
While the mount approach creates a single server at boot time, the controller approach creates a new server per request.

View file

@ -140,6 +140,21 @@ The client will:
- Provide an interactive menu to trigger notifications
- Display all received SSE events in real-time
### 7. Rails Server (`rails/`)
A minimal Rails application that mounts `StreamableHTTPTransport` in its routes, following the "Rails (mount)" pattern from the top-level README.
It demonstrates class-based tools in `app/tools/` and a resource with a read handler.
**Usage:**
```console
$ cd examples/rails
$ bundle install
$ bundle exec puma --port 9292
```
The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough.
### Testing with MCP Inspector
[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers.

3
examples/rails/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/Gemfile.lock
/log/
/tmp/

7
examples/rails/Gemfile Normal file
View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
source "https://rubygems.org"
gem "mcp", path: "../.."
gem "puma"
gem "rails", "~> 8.0"

116
examples/rails/README.md Normal file
View file

@ -0,0 +1,116 @@
# Rails Example MCP Server
A minimal Rails application that serves an MCP server over the Streamable HTTP transport,
following the "Rails (mount)" pattern from the [top-level README](../../README.md).
The application provides:
- `add_tool` tool (`app/tools/add_tool.rb`) - adds two numbers, demonstrates `input_schema`
- `greeting_tool` tool (`app/tools/greeting_tool.rb`) - greets a name, demonstrates `annotations` and `server_context`
- `example://rails/readme` resource - a text resource served via `resources_read_handler`
The MCP server and transport are built once at boot in `config/routes.rb`, where the transport is mounted at `/mcp`.
## Requirements
- Ruby >= 3.2 (required by Rails 8; the `mcp` gem itself supports older Rubies)
- curl >= 7.82 (for the `--json` flag used below)
## Running
```console
$ cd examples/rails
$ bundle install
$ bundle exec puma --port 9292
```
The MCP endpoint is now available at `http://localhost:9292/mcp`.
## Testing with cURL
POST requests must include an `Accept` header that allows both `application/json` and `text/event-stream`,
per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` lines.
1. Initialize a session and capture the session ID:
```console
SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \
-H "Accept: application/json, text/event-stream" \
--json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
| grep -i "^mcp-session-id:" | cut -d' ' -f2 | tr -d '\r')
```
2. Complete the handshake (expect `202 Accepted`):
```console
curl -i http://localhost:9292/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
--json '{"jsonrpc":"2.0","method":"notifications/initialized"}'
```
3. List and call tools:
```console
curl http://localhost:9292/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
--json '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
curl http://localhost:9292/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
--json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_tool","arguments":{"a":5,"b":3}}}'
curl http://localhost:9292/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
--json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"greeting_tool","arguments":{"name":"Rails"}}}'
```
4. List and read the resource:
```console
curl http://localhost:9292/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
--json '{"jsonrpc":"2.0","id":5,"method":"resources/list"}'
curl http://localhost:9292/mcp \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID" \
--json '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"example://rails/readme"}}'
```
5. Optionally, open the standalone SSE stream for server-to-client messages
(in another terminal):
```console
curl -N http://localhost:9292/mcp \
-H "Accept: text/event-stream" \
-H "Mcp-Session-Id: $SESSION_ID"
```
6. End the session:
```console
curl -i -X DELETE http://localhost:9292/mcp -H "Mcp-Session-Id: $SESSION_ID"
```
## Testing with MCP Inspector
Start [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) with `npx @modelcontextprotocol/inspector`,
set Transport Type to "Streamable HTTP", and connect to `http://localhost:9292/mcp`.
## Notes
- `StreamableHTTPTransport` keeps session and SSE state in memory, so it must run in a single process.
Puma runs in single mode (`workers 0`) by default; do not enable clustered mode for this app.
When running multiple instances behind a load balancer, use sticky sessions keyed on the `Mcp-Session-Id` header,
or pass `stateless: true` to the transport.
- The server is built once at boot in `config/routes.rb`, so code reloading is disabled
(`config.enable_reloading = false` in `config/application.rb`).
Restart the server after changing tool or resource code. Note that tool classes cannot be referenced from
`config/initializers` because Zeitwerk has not set up autoloading at that point; routes load late enough that they can.
- For a per-request server with request-specific tools or context, see the "Rails (controller)" section in the top-level README,
which uses `stateless: true` and `transport.handle_request(request)` inside a controller action.

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class AddTool < MCP::Tool
title "Add Tool"
description "A simple example tool that adds two numbers"
input_schema(
properties: {
a: { type: "number" },
b: { type: "number" },
},
required: ["a", "b"],
)
class << self
def call(a:, b:)
MCP::Tool::Response.new([{
type: "text",
text: "The sum of #{a} and #{b} is #{a + b}",
}])
end
end
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class GreetingTool < MCP::Tool
title "Greeting Tool"
description "Greets the given name and reports which app served the request"
input_schema(
properties: {
name: { type: "string" },
},
required: ["name"],
)
annotations(
destructive_hint: false,
idempotent_hint: true,
open_world_hint: false,
read_only_hint: true,
)
class << self
def call(name:, server_context:)
MCP::Tool::Response.new([{
type: "text",
text: "Hello, #{name}! (served by #{server_context[:app_name]})",
}])
end
end
end

5
examples/rails/config.ru Normal file
View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
require_relative "config/environment"
run(Rails.application)

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup"
require "rails"
require "action_controller/railtie"
require "mcp"
module McpRailsExample
class Application < Rails::Application
config.load_defaults(8.0)
# This example has no views, assets, or database.
config.api_only = true
# The MCP server is built once at boot (config/routes.rb) and holds references to the tool classes in app/tools,
# so code reloading is disabled; restart the server after changing tool or resource code.
# A reloading app would need `config.to_prepare` and a way to swap the transport's server instead.
config.enable_reloading = false
config.eager_load = true
config.logger = ActiveSupport::Logger.new($stdout)
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
require_relative "application"
Rails.application.initialize!

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
# The MCP server and its Streamable HTTP transport are built here, once at boot,
# following the "Rails (mount)" pattern from the top-level README.
# Routes are loaded after the application initializes, so the tool classes in app/tools
# are available at this point (they are not yet available in config/initializers,
# where Zeitwerk has not set up autoloading).
#
# The transport keeps session and SSE state in memory, so run a single server process
# (e.g., Puma with `workers 0`, which is the rackup default).
# See the top-level README for multi-instance and stateless deployment notes.
server = MCP::Server.new(
name: "rails_example_server",
title: "Rails Example Server",
version: "1.0.0",
tools: [AddTool, GreetingTool],
resources: [
MCP::Resource.new(
uri: "example://rails/readme",
name: "readme",
title: "Example README",
description: "Describes this Rails example MCP server",
mime_type: "text/plain",
),
],
server_context: { app_name: "mcp_rails_example" },
)
server.resources_read_handler do |params|
[
{
uri: params[:uri],
mimeType: "text/plain",
text: "This resource is served by the Rails example MCP server (Rails #{Rails.version}).",
},
]
end
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
Rails.application.routes.draw do
# `StreamableHTTPTransport` is a Rack app, so it can be mounted directly.
# `mount` directs all HTTP methods on /mcp to the transport, which internally dispatches POST (JSON-RPC messages),
# GET (SSE stream), and DELETE (session termination) per the MCP Streamable HTTP transport spec.
mount(transport => "/mcp")
end