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.
This commit is contained in:
parent
8b1a41aedd
commit
16a6df340c
11 changed files with 275 additions and 0 deletions
|
|
@ -155,6 +155,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.
|
||||
|
|
|
|||
|
|
@ -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
3
examples/rails/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/Gemfile.lock
|
||||
/log/
|
||||
/tmp/
|
||||
7
examples/rails/Gemfile
Normal file
7
examples/rails/Gemfile
Normal 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
116
examples/rails/README.md
Normal 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.
|
||||
22
examples/rails/app/tools/add_tool.rb
Normal file
22
examples/rails/app/tools/add_tool.rb
Normal 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
|
||||
27
examples/rails/app/tools/greeting_tool.rb
Normal file
27
examples/rails/app/tools/greeting_tool.rb
Normal 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
5
examples/rails/config.ru
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "config/environment"
|
||||
|
||||
run(Rails.application)
|
||||
27
examples/rails/config/application.rb
Normal file
27
examples/rails/config/application.rb
Normal 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
|
||||
5
examples/rails/config/environment.rb
Normal file
5
examples/rails/config/environment.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "application"
|
||||
|
||||
Rails.application.initialize!
|
||||
46
examples/rails/config/routes.rb
Normal file
46
examples/rails/config/routes.rb
Normal 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
|
||||
Loading…
Reference in a new issue