|
| 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. |
0 commit comments