Skip to content

Commit d70ca55

Browse files
committed
Add Rails example app
## Motivation and Context The README.md documents the "Rails (mount)" integration pattern, but the repository had no runnable Rails example. This adds a minimal Rails application under examples/rails that mounts StreamableHTTPTransport at /mcp and demonstrates class-based tools and a resource with a read handler. The MCP server is built in config/routes.rb rather than in an initializer because Zeitwerk sets up autoloading after initializers run, so the tool classes in app/tools cannot be referenced there. Code reloading is disabled because the transport holds the server built at boot. The app is booted with puma instead of rackup because rackup inserts Rack::Lint in its development default, which rejects the capitalized response header names that the transport currently emits. Closes modelcontextprotocol#44. ## How Has This Been Tested? The curl walkthrough in examples/rails/README.md has been verified end to end against the running app: initialize and the initialized notification, tools/list, tools/call for both tools, resources/list, resources/read, the standalone SSE stream, and session termination via DELETE. rake test and rake rubocop pass. ## Breaking Changes None. This only adds an example application and documentation links.
1 parent 8b1a41a commit d70ca55

11 files changed

Lines changed: 275 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ end
155155
the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http),
156156
so no additional route configuration is needed.
157157

158+
A complete runnable application using this approach is available in [`examples/rails`](examples/rails).
159+
158160
##### Rails (controller)
159161

160162
While the mount approach creates a single server at boot time, the controller approach creates a new server per request.

examples/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,21 @@ The client will:
140140
- Provide an interactive menu to trigger notifications
141141
- Display all received SSE events in real-time
142142

143+
### 7. Rails Server (`rails/`)
144+
145+
A minimal Rails application that mounts `StreamableHTTPTransport` in its routes, following the "Rails (mount)" pattern from the top-level README.
146+
It demonstrates class-based tools in `app/tools/` and a resource with a read handler.
147+
148+
**Usage:**
149+
150+
```console
151+
$ cd examples/rails
152+
$ bundle install
153+
$ bundle exec puma --port 9292
154+
```
155+
156+
The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough.
157+
143158
### Testing with MCP Inspector
144159

145160
[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers.

examples/rails/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/Gemfile.lock
2+
/log/
3+
/tmp/

examples/rails/Gemfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gem "mcp", path: "../.."
6+
gem "puma"
7+
gem "rails", "~> 8.0"

examples/rails/README.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Rails Example MCP Server
2+
3+
A minimal Rails application that serves an MCP server over the Streamable HTTP transport,
4+
following the "Rails (mount)" pattern from the [top-level README](../../README.md).
5+
6+
The application provides:
7+
8+
- `add_tool` tool (`app/tools/add_tool.rb`) - adds two numbers, demonstrates `input_schema`
9+
- `greeting_tool` tool (`app/tools/greeting_tool.rb`) - greets a name, demonstrates `annotations` and `server_context`
10+
- `example://rails/readme` resource - a text resource served via `resources_read_handler`
11+
12+
The MCP server and transport are built once at boot in `config/routes.rb`, where the transport is mounted at `/mcp`.
13+
14+
## Requirements
15+
16+
- Ruby >= 3.2 (required by Rails 8; the `mcp` gem itself supports older Rubies)
17+
- curl >= 7.82 (for the `--json` flag used below)
18+
19+
## Running
20+
21+
```console
22+
$ cd examples/rails
23+
$ bundle install
24+
$ bundle exec puma --port 9292
25+
```
26+
27+
The MCP endpoint is now available at `http://localhost:9292/mcp`.
28+
29+
## Testing with cURL
30+
31+
POST requests must include an `Accept` header that allows both `application/json` and `text/event-stream`,
32+
per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` lines.
33+
34+
1. Initialize a session and capture the session ID:
35+
36+
```console
37+
SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \
38+
-H "Accept: application/json, text/event-stream" \
39+
--json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
40+
| grep -i "^mcp-session-id:" | cut -d' ' -f2 | tr -d '\r')
41+
```
42+
43+
2. Complete the handshake (expect `202 Accepted`):
44+
45+
```console
46+
curl -i http://localhost:9292/mcp \
47+
-H "Accept: application/json, text/event-stream" \
48+
-H "Mcp-Session-Id: $SESSION_ID" \
49+
--json '{"jsonrpc":"2.0","method":"notifications/initialized"}'
50+
```
51+
52+
3. List and call tools:
53+
54+
```console
55+
curl http://localhost:9292/mcp \
56+
-H "Accept: application/json, text/event-stream" \
57+
-H "Mcp-Session-Id: $SESSION_ID" \
58+
--json '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
59+
60+
curl http://localhost:9292/mcp \
61+
-H "Accept: application/json, text/event-stream" \
62+
-H "Mcp-Session-Id: $SESSION_ID" \
63+
--json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_tool","arguments":{"a":5,"b":3}}}'
64+
65+
curl http://localhost:9292/mcp \
66+
-H "Accept: application/json, text/event-stream" \
67+
-H "Mcp-Session-Id: $SESSION_ID" \
68+
--json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"greeting_tool","arguments":{"name":"Rails"}}}'
69+
```
70+
71+
4. List and read the resource:
72+
73+
```console
74+
curl http://localhost:9292/mcp \
75+
-H "Accept: application/json, text/event-stream" \
76+
-H "Mcp-Session-Id: $SESSION_ID" \
77+
--json '{"jsonrpc":"2.0","id":5,"method":"resources/list"}'
78+
79+
curl http://localhost:9292/mcp \
80+
-H "Accept: application/json, text/event-stream" \
81+
-H "Mcp-Session-Id: $SESSION_ID" \
82+
--json '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"example://rails/readme"}}'
83+
```
84+
85+
5. Optionally, open the standalone SSE stream for server-to-client messages
86+
(in another terminal):
87+
88+
```console
89+
curl -N http://localhost:9292/mcp \
90+
-H "Accept: text/event-stream" \
91+
-H "Mcp-Session-Id: $SESSION_ID"
92+
```
93+
94+
6. End the session:
95+
96+
```console
97+
curl -i -X DELETE http://localhost:9292/mcp -H "Mcp-Session-Id: $SESSION_ID"
98+
```
99+
100+
## Testing with MCP Inspector
101+
102+
Start [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) with `npx @modelcontextprotocol/inspector`,
103+
set Transport Type to "Streamable HTTP", and connect to `http://localhost:9292/mcp`.
104+
105+
## Notes
106+
107+
- `StreamableHTTPTransport` keeps session and SSE state in memory, so it must run in a single process.
108+
Puma runs in single mode (`workers 0`) by default; do not enable clustered mode for this app.
109+
When running multiple instances behind a load balancer, use sticky sessions keyed on the `Mcp-Session-Id` header,
110+
or pass `stateless: true` to the transport.
111+
- The server is built once at boot in `config/routes.rb`, so code reloading is disabled
112+
(`config.enable_reloading = false` in `config/application.rb`).
113+
Restart the server after changing tool or resource code. Note that tool classes cannot be referenced from
114+
`config/initializers` because Zeitwerk has not set up autoloading at that point; routes load late enough that they can.
115+
- For a per-request server with request-specific tools or context, see the "Rails (controller)" section in the top-level README,
116+
which uses `stateless: true` and `transport.handle_request(request)` inside a controller action.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
class AddTool < MCP::Tool
4+
title "Add Tool"
5+
description "A simple example tool that adds two numbers"
6+
input_schema(
7+
properties: {
8+
a: { type: "number" },
9+
b: { type: "number" },
10+
},
11+
required: ["a", "b"],
12+
)
13+
14+
class << self
15+
def call(a:, b:)
16+
MCP::Tool::Response.new([{
17+
type: "text",
18+
text: "The sum of #{a} and #{b} is #{a + b}",
19+
}])
20+
end
21+
end
22+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
class GreetingTool < MCP::Tool
4+
title "Greeting Tool"
5+
description "Greets the given name and reports which app served the request"
6+
input_schema(
7+
properties: {
8+
name: { type: "string" },
9+
},
10+
required: ["name"],
11+
)
12+
annotations(
13+
destructive_hint: false,
14+
idempotent_hint: true,
15+
open_world_hint: false,
16+
read_only_hint: true,
17+
)
18+
19+
class << self
20+
def call(name:, server_context:)
21+
MCP::Tool::Response.new([{
22+
type: "text",
23+
text: "Hello, #{name}! (served by #{server_context[:app_name]})",
24+
}])
25+
end
26+
end
27+
end

examples/rails/config.ru

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "config/environment"
4+
5+
run(Rails.application)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
4+
5+
require "bundler/setup"
6+
7+
require "rails"
8+
require "action_controller/railtie"
9+
10+
require "mcp"
11+
12+
module McpRailsExample
13+
class Application < Rails::Application
14+
config.load_defaults(8.0)
15+
16+
# This example has no views, assets, or database.
17+
config.api_only = true
18+
19+
# The MCP server is built once at boot (config/routes.rb) and holds references to the tool classes in app/tools,
20+
# so code reloading is disabled; restart the server after changing tool or resource code.
21+
# A reloading app would need `config.to_prepare` and a way to swap the transport's server instead.
22+
config.enable_reloading = false
23+
config.eager_load = true
24+
25+
config.logger = ActiveSupport::Logger.new($stdout)
26+
end
27+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "application"
4+
5+
Rails.application.initialize!

0 commit comments

Comments
 (0)