Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions examples/rails/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/Gemfile.lock
/log/
/tmp/
7 changes: 7 additions & 0 deletions examples/rails/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

source "https://rubygems.org"

gem "mcp", path: "../.."
gem "puma"
gem "rails", "~> 8.0"
116 changes: 116 additions & 0 deletions examples/rails/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions examples/rails/app/tools/add_tool.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions examples/rails/app/tools/greeting_tool.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions examples/rails/config.ru
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

require_relative "config/environment"

run(Rails.application)
27 changes: 27 additions & 0 deletions examples/rails/config/application.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions examples/rails/config/environment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

require_relative "application"

Rails.application.initialize!
46 changes: 46 additions & 0 deletions examples/rails/config/routes.rb
Original file line number Diff line number Diff line change
@@ -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