## Motivation and Context The runnable examples drifted from the SDK they ship with: - POST requests whose `Accept` header does not cover both `application/json` and `text/event-stream` are answered 406, so the cURL walkthroughs no longer worked as printed. - `MCP::Client#connect` now defaults to `mode: :auto`, which adopts the sessionless modern lifecycle (2026-07-28) against a dual-era server; the Streamable HTTP client example requires a session and broke against its own server. - POST responses on an established session arrive as an SSE stream, which the hand-rolled HTTP client did not parse. - `notification_tool` claimed to send SSE notifications but only returned a `Tool::Response`. - `http_client.rb` read a resource URI the server never registered and skipped `notifications/initialized`. Handshake examples now offer the latest handshake protocol version (`2025-11-25`) instead of `2024-11-05`, the Streamable HTTP client pins `mode: :legacy` for its session-based demo, `notification_tool` reports real `notifications/progress` events requested through `_meta.progressToken`, and the interactive client streams those events from the POST response so they are visible on screen. The modern lifecycle itself had no runnable example, so a new server and client pair (`modern_http_server.rb`, `modern_http_client.rb`) shows the sessionless flow end to end: `server/discover` capability discovery, the per-request `_meta` envelope with the `Mcp-Method` and `Mcp-Name` headers (stamped by the SDK client automatically), `resultType` stamping, SEP-2549 cache hints, a SEP-2322 multi round-trip `deploy` tool resumed automatically by the client's elicitation handler, and the removal of legacy-only methods such as `ping`. The same server keeps serving the legacy `initialize` flow, routed per request by the `MCP-Protocol-Version` header. `examples/README.md` documents the pair and adds a cURL walkthrough covering the modern headers, the envelope, and the multi round-trip exchange. ## How Has This Been Tested? - `ruby examples/stdio_client.rb` against `stdio_server.rb` - `ruby examples/http_client.rb` against `http_server.rb` - `ruby examples/streamable_http_client.rb` against `streamable_http_server.rb`, including the progress stream - `ruby examples/modern_http_client.rb` against `modern_http_server.rb` (discover, modern adoption, cache hints, `resultType`, the multi round-trip resume, `ping` rejection) - The cURL walkthroughs in the banners and `examples/README.md`, including the `requestState` echo and a legacy `initialize` against the modern example server ## Breaking Changes None.
116 lines
4.5 KiB
Markdown
116 lines
4.5 KiB
Markdown
# 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-11-25","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.
|