ruby-sdk/examples/http_server.rb
Koichi ITO bfc6e551a3 Update examples for the 2026-07-28 era
## 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.
2026-08-28 12:03:52 +09:00

185 lines
4.8 KiB
Ruby

# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "mcp"
require "rack/cors"
require "rackup"
require "json"
require "logger"
# Create a simple tool
class ExampleTool < MCP::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
# Create a simple prompt
class ExamplePrompt < MCP::Prompt
description "A simple example prompt that echoes back its arguments"
arguments [
MCP::Prompt::Argument.new(
name: "message",
description: "The message to echo back",
required: true,
),
]
class << self
def template(args, server_context:)
MCP::Prompt::Result.new(
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new(args[:message]),
),
],
)
end
end
end
# Set up the server
server = MCP::Server.new(
name: "example_http_server",
version: "1.0.0",
tools: [ExampleTool],
prompts: [ExamplePrompt],
resources: [
MCP::Resource.new(
uri: "https://test_resource.invalid",
name: "test-resource",
title: "Test Resource",
description: "Test resource that echoes back the uri as its content",
mime_type: "text/plain",
),
],
)
server.define_tool(
name: "echo",
description: "A simple example tool that echoes back its arguments",
input_schema: { properties: { message: { type: "string" } }, required: ["message"] },
) do |message:|
MCP::Tool::Response.new(
[
{
type: "text",
text: "Hello from echo tool! Message: #{message}",
},
],
)
end
server.resources_read_handler do |params|
[{
uri: params[:uri],
mimeType: "text/plain",
text: "Hello from HTTP server resource!",
}]
end
# Create the Streamable HTTP transport
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
# Rack middleware for MCP-specific request/response logging.
class McpRequestLogger
def initialize(app)
@app = app
@logger = Logger.new($stdout)
@logger.formatter = proc { |_severity, _datetime, _progname, msg| "[MCP] #{msg}\n" }
end
def call(env)
if env["REQUEST_METHOD"] == "POST"
body = env["rack.input"].read
env["rack.input"].rewind
begin
parsed = JSON.parse(body)
@logger.info("Request: #{parsed["method"]} (id: #{parsed["id"]})")
@logger.debug("Request body: #{JSON.pretty_generate(parsed)}")
rescue JSON::ParserError
@logger.warn("Request body (raw): #{body}")
end
end
status, headers, response_body = @app.call(env)
if response_body.is_a?(Array) && !response_body.empty? && response_body.first
begin
parsed = JSON.parse(response_body.first)
if parsed["error"]
@logger.error("Response error: #{parsed["error"]["message"]}")
else
@logger.info("Response: #{parsed["result"] ? "success" : "empty"} (id: #{parsed["id"]})")
end
@logger.debug("Response body: #{JSON.pretty_generate(parsed)}")
rescue JSON::ParserError
@logger.warn("Response body (raw): #{response_body}")
end
end
[status, headers, response_body]
end
end
# Build the Rack application with middleware.
# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app.
rack_app = Rack::Builder.new do
# Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector)
# WARNING: origins("*") allows all origins. Restrict this in production.
use(Rack::Cors) do
allow do
origins("*")
resource(
"*",
headers: :any,
methods: [:get, :post, :delete, :options],
expose: ["Mcp-Session-Id"],
)
end
end
# Use CommonLogger for standard HTTP request logging
use(Rack::CommonLogger, Logger.new($stdout))
use(Rack::ShowExceptions)
use(McpRequestLogger)
run(transport)
end
# Start the server
puts <<~MESSAGE
Starting MCP HTTP server on http://localhost:9292
Use POST requests to initialize and send JSON-RPC commands
Example initialization:
curl -i http://localhost:9292 \\
-H "Accept: application/json, text/event-stream" \\
--json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
The server will return a session ID in the Mcp-Session-Id header.
Use this session ID for subsequent requests.
Press Ctrl+C to stop the server
MESSAGE
# Run the server
# Use Rackup to run the server
Rackup::Handler.get("puma").run(rack_app, Port: 9292, Host: "localhost")