From 16a6df340c05eeb506c83127d87d21859028241d Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 9 Jul 2026 01:33:56 +0900 Subject: [PATCH] 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. --- README.md | 2 + examples/README.md | 15 +++ examples/rails/.gitignore | 3 + examples/rails/Gemfile | 7 ++ examples/rails/README.md | 116 ++++++++++++++++++++++ examples/rails/app/tools/add_tool.rb | 22 ++++ examples/rails/app/tools/greeting_tool.rb | 27 +++++ examples/rails/config.ru | 5 + examples/rails/config/application.rb | 27 +++++ examples/rails/config/environment.rb | 5 + examples/rails/config/routes.rb | 46 +++++++++ 11 files changed, 275 insertions(+) create mode 100644 examples/rails/.gitignore create mode 100644 examples/rails/Gemfile create mode 100644 examples/rails/README.md create mode 100644 examples/rails/app/tools/add_tool.rb create mode 100644 examples/rails/app/tools/greeting_tool.rb create mode 100644 examples/rails/config.ru create mode 100644 examples/rails/config/application.rb create mode 100644 examples/rails/config/environment.rb create mode 100644 examples/rails/config/routes.rb diff --git a/README.md b/README.md index b986c262..1bfc607b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/examples/README.md b/examples/README.md index 6328c4e5..86931c5a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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. diff --git a/examples/rails/.gitignore b/examples/rails/.gitignore new file mode 100644 index 00000000..4910c636 --- /dev/null +++ b/examples/rails/.gitignore @@ -0,0 +1,3 @@ +/Gemfile.lock +/log/ +/tmp/ diff --git a/examples/rails/Gemfile b/examples/rails/Gemfile new file mode 100644 index 00000000..a1494d89 --- /dev/null +++ b/examples/rails/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "mcp", path: "../.." +gem "puma" +gem "rails", "~> 8.0" diff --git a/examples/rails/README.md b/examples/rails/README.md new file mode 100644 index 00000000..6a599c3c --- /dev/null +++ b/examples/rails/README.md @@ -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. diff --git a/examples/rails/app/tools/add_tool.rb b/examples/rails/app/tools/add_tool.rb new file mode 100644 index 00000000..b3bf5068 --- /dev/null +++ b/examples/rails/app/tools/add_tool.rb @@ -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 diff --git a/examples/rails/app/tools/greeting_tool.rb b/examples/rails/app/tools/greeting_tool.rb new file mode 100644 index 00000000..00c8c3f8 --- /dev/null +++ b/examples/rails/app/tools/greeting_tool.rb @@ -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 diff --git a/examples/rails/config.ru b/examples/rails/config.ru new file mode 100644 index 00000000..14f9aab5 --- /dev/null +++ b/examples/rails/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" + +run(Rails.application) diff --git a/examples/rails/config/application.rb b/examples/rails/config/application.rb new file mode 100644 index 00000000..a61ce76c --- /dev/null +++ b/examples/rails/config/application.rb @@ -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 diff --git a/examples/rails/config/environment.rb b/examples/rails/config/environment.rb new file mode 100644 index 00000000..e8173e05 --- /dev/null +++ b/examples/rails/config/environment.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "application" + +Rails.application.initialize! diff --git a/examples/rails/config/routes.rb b/examples/rails/config/routes.rb new file mode 100644 index 00000000..76f3c278 --- /dev/null +++ b/examples/rails/config/routes.rb @@ -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