Skip to content

Commit 16a6df3

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 #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. ## Breaking Changes None. This only adds an example application and documentation links.
1 parent 8b1a41a commit 16a6df3

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)