## 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.
225 lines
6.7 KiB
Ruby
225 lines
6.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
require "mcp"
|
|
require "net/http"
|
|
require "uri"
|
|
require "json"
|
|
require "logger"
|
|
require "event_stream_parser"
|
|
|
|
SERVER_URL = "http://localhost:9393"
|
|
|
|
def create_logger
|
|
logger = Logger.new($stdout)
|
|
logger.formatter = proc do |severity, datetime, _progname, msg|
|
|
"[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
|
|
end
|
|
logger
|
|
end
|
|
|
|
# MCP::Client::HTTP opens the optional GET SSE stream itself once a server-request handler
|
|
# (e.g. `client.on_elicitation`) is registered. This example instead opens the stream with
|
|
# raw `Net::HTTP` to show the SSE wire format. The GET stream carries server-initiated messages
|
|
# that are not tied to a client request; request-scoped notifications such as progress arrive on
|
|
# the POST's own SSE response stream (see `stream_tool_call` below).
|
|
def connect_sse(session_id, logger)
|
|
uri = URI(SERVER_URL)
|
|
|
|
logger.info("Connecting to SSE stream...")
|
|
|
|
Net::HTTP.start(uri.host, uri.port) do |http|
|
|
request = Net::HTTP::Get.new(uri)
|
|
request["Mcp-Session-Id"] = session_id
|
|
request["Accept"] = "text/event-stream"
|
|
request["Cache-Control"] = "no-cache"
|
|
|
|
http.request(request) do |response|
|
|
if response.code == "200"
|
|
logger.info("SSE stream connected successfully")
|
|
|
|
parser = EventStreamParser::Parser.new
|
|
response.read_body do |chunk|
|
|
parser.feed(chunk) do |type, data, _id|
|
|
if type.empty?
|
|
logger.info("SSE event: #{data}")
|
|
else
|
|
logger.info("SSE event (#{type}): #{data}")
|
|
end
|
|
end
|
|
end
|
|
else
|
|
logger.error("Failed to connect to SSE: #{response.code} #{response.message}")
|
|
end
|
|
end
|
|
end
|
|
rescue Interrupt
|
|
logger.info("SSE connection interrupted")
|
|
rescue => e
|
|
logger.error("SSE connection error: #{e.message}")
|
|
end
|
|
|
|
def print_response(response)
|
|
if response.nil?
|
|
puts "Response accepted; watch the SSE stream for the server response."
|
|
else
|
|
puts "Response: #{JSON.pretty_generate(response)}"
|
|
end
|
|
end
|
|
|
|
# Calls a tool with a raw streaming POST so each SSE event on the response stream is printed as
|
|
# it arrives: the `notifications/progress` events requested via `_meta.progressToken`,
|
|
# then the final JSON-RPC response. `MCP::Client#call_tool` reads the same stream but consumes
|
|
# the progress notifications internally, so the raw request makes them visible here.
|
|
def stream_tool_call(session_id, request_id, name:, arguments:, logger:)
|
|
uri = URI(SERVER_URL)
|
|
|
|
body = {
|
|
jsonrpc: "2.0",
|
|
id: request_id,
|
|
method: "tools/call",
|
|
params: {
|
|
name: name,
|
|
arguments: arguments,
|
|
_meta: { progressToken: "streamable-http-client" },
|
|
},
|
|
}
|
|
|
|
Net::HTTP.start(uri.host, uri.port) do |http|
|
|
request = Net::HTTP::Post.new(uri)
|
|
request["Content-Type"] = "application/json"
|
|
request["Accept"] = "application/json, text/event-stream"
|
|
request["Mcp-Session-Id"] = session_id
|
|
request.body = JSON.generate(body)
|
|
|
|
http.request(request) do |response|
|
|
parser = EventStreamParser::Parser.new
|
|
response.read_body do |chunk|
|
|
parser.feed(chunk) do |_type, data, _id|
|
|
logger.info("SSE event: #{data}")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def main
|
|
logger = create_logger
|
|
|
|
puts <<~MESSAGE
|
|
MCP Streamable HTTP Client
|
|
Make sure the server is running (ruby examples/streamable_http_server.rb)
|
|
#{"=" * 60}
|
|
MESSAGE
|
|
|
|
http_transport = MCP::Client::HTTP.new(url: SERVER_URL)
|
|
client = MCP::Client.new(transport: http_transport)
|
|
sse_thread = nil
|
|
raw_request_id = 0
|
|
|
|
begin
|
|
puts "=== Initializing session ==="
|
|
|
|
# The default `mode: :auto` adopts the sessionless modern lifecycle (2026-07-28)
|
|
# when the server supports it. This example demonstrates the legacy session's SSE stream,
|
|
# so pin the legacy `initialize` handshake.
|
|
server_info = client.connect(
|
|
client_info: { name: "streamable-http-client", version: "1.0" },
|
|
mode: :legacy,
|
|
)
|
|
|
|
puts <<~MESSAGE
|
|
ID: #{http_transport.session_id}
|
|
Version: #{http_transport.protocol_version}
|
|
Server: #{server_info["serverInfo"]}
|
|
MESSAGE
|
|
|
|
unless http_transport.session_id
|
|
logger.error("No session ID received; this example requires a stateful Streamable HTTP session.")
|
|
return
|
|
end
|
|
|
|
puts "=== Listing tools ==="
|
|
tools = client.tools
|
|
tools.each { |tool| puts " - #{tool.name}: #{tool.description}" }
|
|
|
|
echo_tool = tools.find { |tool| tool.name == "echo" }
|
|
notification_tool = tools.find { |tool| tool.name == "notification_tool" }
|
|
|
|
sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) }
|
|
sleep(1)
|
|
|
|
loop do
|
|
puts <<~MENU.chomp
|
|
|
|
=== Available Actions ===
|
|
1. Send notification (triggers SSE event)
|
|
2. Echo message
|
|
3. Show cached tools
|
|
0. Exit
|
|
|
|
Choose an action:#{" "}
|
|
MENU
|
|
|
|
case gets.chomp
|
|
when "1"
|
|
if notification_tool
|
|
print("Enter notification message: ")
|
|
message = gets.chomp
|
|
print("Enter delay in seconds (0 for immediate): ")
|
|
delay = gets.chomp.to_f
|
|
|
|
puts "=== Calling tool: notification_tool (raw streaming POST) ==="
|
|
raw_request_id += 1
|
|
stream_tool_call(
|
|
http_transport.session_id,
|
|
"raw-#{raw_request_id}",
|
|
name: notification_tool.name,
|
|
arguments: { message: message, delay: delay },
|
|
logger: logger,
|
|
)
|
|
else
|
|
puts "notification_tool not available"
|
|
end
|
|
when "2"
|
|
if echo_tool
|
|
print("Enter message to echo: ")
|
|
message = gets.chomp
|
|
|
|
puts "=== Calling tool: echo ==="
|
|
response = client.call_tool(tool: echo_tool, arguments: { message: message })
|
|
print_response(response)
|
|
else
|
|
puts "echo tool not available"
|
|
end
|
|
when "3"
|
|
puts "=== Cached tools ==="
|
|
tools.each { |tool| puts " - #{tool.name}: #{tool.description}" }
|
|
when "0"
|
|
logger.info("Exiting...")
|
|
break
|
|
else
|
|
puts "Invalid choice"
|
|
end
|
|
end
|
|
rescue MCP::Client::SessionExpiredError => e
|
|
logger.error("Session expired: #{e.message}")
|
|
rescue MCP::Client::RequestHandlerError => e
|
|
logger.error("Request error: #{e.message}")
|
|
rescue Interrupt
|
|
logger.info("Client interrupted")
|
|
rescue => e
|
|
logger.error("Error: #{e.message}")
|
|
logger.error(e.backtrace.first(5).join("\n"))
|
|
ensure
|
|
sse_thread.kill if sse_thread&.alive?
|
|
|
|
if http_transport.connected?
|
|
puts "=== Closing session ==="
|
|
http_transport.close
|
|
puts "Session closed"
|
|
end
|
|
end
|
|
end
|
|
|
|
main
|