ruby-sdk/examples/http_client.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

228 lines
5.3 KiB
Ruby

# frozen_string_literal: true
require "net/http"
require "json"
require "uri"
# Simple HTTP client example for interacting with the MCP HTTP server.
class MCPHTTPClient
def initialize(base_url = "http://localhost:9292")
@base_url = base_url
@session_id = nil
end
def send_request(method, params = nil, id = nil)
uri = URI(@base_url)
http = Net::HTTP.new(uri.host, uri.port)
request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path)
request["Content-Type"] = "application/json"
request["Mcp-Session-Id"] = @session_id if @session_id
body = {
jsonrpc: "2.0",
method: method,
params: params,
id: id || rand(10000),
}.compact
request.body = body.to_json
response = http.request(request)
# Store session ID if provided.
if response["Mcp-Session-Id"]
@session_id = response["Mcp-Session-Id"]
puts "Session ID: #{@session_id}"
end
parse_response_body(response)
end
# In the transport's default SSE mode, POST responses on an established session arrive as
# a Server-Sent Events stream whose `data:` line carries the JSON-RPC response; unwrap it
# before parsing.
def parse_response_body(response)
body = response.body
if response["Content-Type"]&.start_with?("text/event-stream")
data_lines = body.lines.select { |line| line.start_with?("data:") }
body = data_lines.map { |line| line.sub(/\Adata:\s*/, "") }.join
end
JSON.parse(body)
end
def send_notification(method, params = nil)
uri = URI(@base_url)
http = Net::HTTP.new(uri.host, uri.port)
request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path)
request["Content-Type"] = "application/json"
request["Mcp-Session-Id"] = @session_id if @session_id
# Notifications carry no `id` and receive no JSON-RPC response body.
request.body = {
jsonrpc: "2.0",
method: method,
params: params,
}.compact.to_json
http.request(request)
end
def initialize_session
puts "=== Initializing session ==="
result = send_request("initialize", {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: {
name: "example_client",
version: "1.0",
},
})
puts "Response: #{JSON.pretty_generate(result)}"
result
end
def notify_initialized
puts "=== Sending notifications/initialized ==="
response = send_notification("notifications/initialized")
puts "Response status: #{response.code} #{response.message}"
response
end
def ping
puts "=== Sending ping ==="
result = send_request("ping")
puts "Response: #{JSON.pretty_generate(result)}"
result
end
def list_tools
puts "=== Listing tools ==="
result = send_request("tools/list")
puts "Response: #{JSON.pretty_generate(result)}"
result
end
def call_tool(name, arguments)
puts "=== Calling tool: #{name} ==="
result = send_request("tools/call", {
name: name,
arguments: arguments,
})
puts "Response: #{JSON.pretty_generate(result)}"
result
end
def list_prompts
puts "=== Listing prompts ==="
result = send_request("prompts/list")
puts "Response: #{JSON.pretty_generate(result)}"
result
end
def get_prompt(name, arguments)
puts "=== Getting prompt: #{name} ==="
result = send_request("prompts/get", {
name: name,
arguments: arguments,
})
puts "Response: #{JSON.pretty_generate(result)}"
result
end
def list_resources
puts "=== Listing resources ==="
result = send_request("resources/list")
puts "Response: #{JSON.pretty_generate(result)}"
result
end
def read_resource(uri)
puts "=== Reading resource: #{uri} ==="
result = send_request("resources/read", {
uri: uri,
})
puts "Response: #{JSON.pretty_generate(result)}"
result
end
def close_session
return unless @session_id
puts "=== Closing session ==="
uri = URI(@base_url)
http = Net::HTTP.new(uri.host, uri.port)
request = Net::HTTP::Delete.new(uri.path.empty? ? "/" : uri.path)
request["Mcp-Session-Id"] = @session_id
response = http.request(request)
result = JSON.parse(response.body)
puts "Response: #{JSON.pretty_generate(result)}"
@session_id = nil
result
end
end
def main
puts <<~MESSAGE
MCP HTTP Client Example
Make sure the HTTP server is running (ruby examples/http_server.rb)
#{"=" * 50}
MESSAGE
client = MCPHTTPClient.new
begin
# Initialize session.
client.initialize_session
# Complete the handshake.
client.notify_initialized
# Test ping.
client.ping
# List available tools.
client.list_tools
# Call the example_tool (note: snake_case name).
client.call_tool("example_tool", { a: 5, b: 3 })
# Call the echo tool.
client.call_tool("echo", { message: "Hello from client!" })
# List prompts.
client.list_prompts
# Get a prompt (note: snake_case name).
client.get_prompt("example_prompt", { message: "This is a test message" })
# List resources.
client.list_resources
# Read a resource (the URI registered by http_server.rb).
client.read_resource("https://test_resource.invalid")
rescue => e
puts "Error: #{e.message}"
puts e.backtrace
ensure
# Clean up session.
client.close_session
end
end
main