From bfc6e551a3a9b738def565ae90fe2814706265bd Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 17 Aug 2026 03:07:22 +0900 Subject: [PATCH] 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. --- examples/README.md | 112 +++++++++++++++++++++--- examples/http_client.rb | 73 ++++++++++++---- examples/http_server.rb | 5 +- examples/modern_http_client.rb | 133 +++++++++++++++++++++++++++++ examples/modern_http_server.rb | 126 +++++++++++++++++++++++++++ examples/rails/README.md | 2 +- examples/streamable_http_client.rb | 62 ++++++++++++-- examples/streamable_http_server.rb | 39 ++++++--- 8 files changed, 501 insertions(+), 51 deletions(-) create mode 100644 examples/modern_http_client.rb create mode 100644 examples/modern_http_server.rb diff --git a/examples/README.md b/examples/README.md index 86931c5a..af1828a7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -98,7 +98,7 @@ A specialized HTTP server designed to test and demonstrate Server-Sent Events (S **Available Tools:** -- `notification_tool` - Send custom SSE notifications with optional delays +- `notification_tool` - Sends progress notifications over SSE, with optional delays - `echo` - Simple echo tool for basic testing **Usage:** @@ -124,14 +124,15 @@ An interactive client that connects to the SSE stream and provides a menu-driven 1. Start the SSE test server in one terminal: - ```console - $ ruby examples/streamable_http_server.rb - ``` +```console +$ ruby examples/streamable_http_server.rb +``` 2. Run the SSE test client in another terminal: - ```console - $ ruby examples/streamable_http_client.rb - ``` + +```console +$ ruby examples/streamable_http_client.rb +``` The client will: @@ -155,6 +156,34 @@ $ bundle exec puma --port 9292 The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough. +### 8. Modern Lifecycle HTTP Server / Client (`modern_http_server.rb`, `modern_http_client.rb`) + +A server and client pair demonstrating the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake and per-session state with sessionless, self-contained requests. + +**Features:** + +- `server/discover` capability discovery before (or instead of) a handshake +- The per-request `_meta` envelope and `Mcp-Method` / `Mcp-Name` headers, stamped by the SDK automatically +- `resultType` stamping and SEP-2549 cache hints (`ttlMs`, `cacheScope`) on results +- A multi round-trip `deploy` tool (SEP-2322), resumed automatically by the client's elicitation handler +- The removal of legacy-only methods such as `ping` + +**Usage:** + +1. Start the server in one terminal: + +```console +$ ruby examples/modern_http_server.rb +``` + +2. Run the client in another terminal: + +```console +$ ruby examples/modern_http_client.rb +``` + +The same server still accepts the legacy `initialize` flow: the transport routes each request to the legacy or modern lifecycle by its `MCP-Protocol-Version` header. + ### Testing with MCP Inspector [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers. @@ -188,24 +217,78 @@ You can also test SSE functionality manually using cURL: ```console SESSION_ID=$(curl -D - -s -o /dev/null http://localhost:9393 \ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') ``` -2. Connect to SSE stream (in one terminal): +2. Optionally connect the standalone SSE stream, which carries server-initiated messages that are not tied to a request (in another terminal): ```console curl -i -N -H "Mcp-Session-Id: $SESSION_ID" http://localhost:9393 ``` -3. Trigger notifications (in another terminal): +3. Call the notification tool. The `notifications/progress` events (requested via the `progressToken` in `_meta`) and the final response arrive as SSE events on the POST response itself: ```console -# Send immediate notification curl -i http://localhost:9393 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: $SESSION_ID" \ - --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}' + --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!","delay":0.5},"_meta":{"progressToken":"curl-progress"}}}' +``` + +### Testing the modern lifecycle with cURL + +The modern lifecycle (2026-07-28, SEP-2575) is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. Start `examples/modern_http_server.rb` and walk it manually: + +1. Probe capabilities with `server/discover`: + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + --json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}' +``` + +2. List tools (the `MCP-Protocol-Version` header selects the era, `Mcp-Method` mirrors the method, and `params._meta` carries the SEP-2575 envelope): + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/list" \ + --json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +3. Call a tool (name-bearing methods additionally mirror the name in the `Mcp-Name` header): + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: greet" \ + --json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' ``` +4. Observe a multi round-trip result (SEP-2322), then resume it by echoing the `requestState` back together with the answer. Declaring the `elicitation` capability in the envelope is required before the server may embed elicitation requests: + +```console +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: deploy" \ + --json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}' + +curl -s http://localhost:9494 \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: deploy" \ + --json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"inputResponses":{"environment":{"action":"accept","content":{"environment":"staging"}}},"requestState":"{\"app\":\"storefront\"}","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}' +``` + +The same server still accepts the legacy flow from the previous sections, routed by the `MCP-Protocol-Version` header. + ## Streamable HTTP Transport Details ### Protocol Flow @@ -236,13 +319,15 @@ Initialize a session: ```console curl -i http://localhost:9292 \ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + -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"}}}' ``` List tools (using the session ID from initialization): ```console curl -i http://localhost:9292 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ --json '{"jsonrpc":"2.0","method":"tools/list","id":2}' ``` @@ -251,6 +336,7 @@ Call a tool: ```console curl -i http://localhost:9292 \ + -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"example_tool","arguments":{"a":5,"b":3}}}' ``` diff --git a/examples/http_client.rb b/examples/http_client.rb index 4ef95c75..b61b5632 100644 --- a/examples/http_client.rb +++ b/examples/http_client.rb @@ -4,7 +4,7 @@ require "json" require "uri" -# Simple HTTP client example for interacting with the MCP HTTP server +# Simple HTTP client example for interacting with the MCP HTTP server. class MCPHTTPClient def initialize(base_url = "http://localhost:9292") @base_url = base_url @@ -30,19 +30,51 @@ def send_request(method, params = nil, id = nil) response = http.request(request) - # Store session ID if provided + # Store session ID if provided. if response["Mcp-Session-Id"] @session_id = response["Mcp-Session-Id"] puts "Session ID: #{@session_id}" end - JSON.parse(response.body) + 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: "2024-11-05", + protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "example_client", @@ -54,6 +86,14 @@ def initialize_session 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") @@ -147,37 +187,40 @@ def main client = MCPHTTPClient.new begin - # Initialize session + # Initialize session. client.initialize_session - # Test ping + # Complete the handshake. + client.notify_initialized + + # Test ping. client.ping - # List available tools + # List available tools. client.list_tools - # Call the example_tool (note: snake_case name) + # Call the example_tool (note: snake_case name). client.call_tool("example_tool", { a: 5, b: 3 }) - # Call the echo tool + # Call the echo tool. client.call_tool("echo", { message: "Hello from client!" }) - # List prompts + # List prompts. client.list_prompts - # Get a prompt (note: snake_case name) + # Get a prompt (note: snake_case name). client.get_prompt("example_prompt", { message: "This is a test message" }) - # List resources + # List resources. client.list_resources - # Read a resource - client.read_resource("test_resource") + # 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 + # Clean up session. client.close_session end end diff --git a/examples/http_server.rb b/examples/http_server.rb index 4f3edee0..33d8d807 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -56,6 +56,7 @@ def template(args, server_context:) # Set up the server server = MCP::Server.new( name: "example_http_server", + version: "1.0.0", tools: [ExampleTool], prompts: [ExamplePrompt], resources: [ @@ -169,7 +170,9 @@ def call(env) 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 --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + 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. diff --git a/examples/modern_http_client.rb b/examples/modern_http_client.rb new file mode 100644 index 00000000..ca2cd1fa --- /dev/null +++ b/examples/modern_http_client.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "json" + +# Client example for the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake +# and per-session state with sessionless, self-contained requests. +class MCPModernHTTPClient + def initialize(base_url = "http://localhost:9494") + @transport = MCP::Client::HTTP.new(url: base_url) + @client = MCP::Client.new(transport: @transport) + end + + # `server/discover` (SEP-2575) is sessionless capability discovery that works before, or instead of, `connect`. + def discover + puts "=== Discovering server capabilities (server/discover) ===" + result = @client.discover + puts <<~MESSAGE + Supported versions: #{result.supported_versions.inspect} + Server: #{result.server_info} + Cache hints: ttlMs=#{result.ttl_ms}, cacheScope=#{result.cache_scope} (SEP-2549) + Instructions: #{result.instructions} + MESSAGE + + result + end + + # The handler answers `elicitation/create` requests embedded in SEP-2322 `input_required` results; once registered, + # `call_tool` resumes those results automatically. + # Register it before `connect` so the connection never needs a server-to-client route. + def register_elicitation_handler + @client.on_elicitation do |params| + puts " Server asked: #{params["message"]}" + + { action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]) } + end + end + + # `mode: :modern` requires the modern lifecycle and fails on legacy-only servers; + # the default `:auto` probes `server/discover` first and falls back to the legacy `initialize` handshake. + # The `elicitation` capability must be declared before the server may embed elicitation requests. + def connect + puts "=== Adopting the modern lifecycle ===" + result = @client.connect( + client_info: { name: "modern-http-client", version: "1.0" }, + capabilities: { elicitation: {} }, + mode: :modern, + ) + puts <<~MESSAGE + Modern connection: #{@transport.modern?} + Protocol version: #{@client.protocol_version} + MESSAGE + + result + end + + # Modern requests are self-contained: the SDK stamps the `_meta` envelope (protocol version, + # client info, capabilities) and the Mcp-Method and Mcp-Name headers on every request automatically. + def list_tools + puts "=== Listing tools ===" + page = @client.list_tools + page.tools.each { |tool| puts " - #{tool.name}: #{tool.description}" } + puts "Cache hints: ttlMs=#{page.ttl_ms}, cacheScope=#{page.cache_scope}" + + page + end + + def call_tool(name, arguments) + puts "=== Calling tool: #{name} ===" + response = @client.call_tool(name: name, arguments: arguments) + puts <<~MESSAGE + resultType: #{response.dig("result", "resultType")} + Response: #{response.dig("result", "content", 0, "text")} + MESSAGE + + response + end + + # The modern lifecycle removed `initialize`, `ping`, `logging/setLevel`, and resource subscriptions (SEP-2575); + # calling one is an error. + def ping + puts "=== Calling removed method: ping ===" + @client.ping + rescue MCP::Client::RequestHandlerError => e + puts "ping was removed by the modern lifecycle (SEP-2575): #{e.message}" + end + + def close + # No session exists on a modern connection; this only clears local state. + @transport.close + end +end + +puts <<~MESSAGE + MCP Modern Lifecycle Client (2026-07-28) + Make sure the server is running (ruby examples/modern_http_server.rb) + #{"=" * 60} +MESSAGE + +client = MCPModernHTTPClient.new + +begin + # Probe the server before adopting a lifecycle. + client.discover + + # Answer the elicitation requests the server embeds in its results. + client.register_elicitation_handler + + # Adopt the modern lifecycle. + client.connect + + # List available tools. + client.list_tools + + # Call a plain single round-trip tool. + client.call_tool("greet", { name: "MCP" }) + + # Call a multi round-trip tool (SEP-2322): the driver fulfills the embedded elicitation via the handler registered above + # and re-issues the call with `inputResponses` plus the echoed `requestState`. Without a handler, `call_tool` raises + # `MCP::Client::InputRequiredError` for manual driving via the `input_responses:` / `request_state:` keywords. + client.call_tool("deploy", { app: "storefront" }) + + # Call a method the modern lifecycle removed. + client.ping +rescue => e + puts <<~MESSAGE + Error: #{e.message} + #{e.backtrace.join("\n")} + MESSAGE +ensure + client.close +end diff --git a/examples/modern_http_server.rb b/examples/modern_http_server.rb new file mode 100644 index 00000000..b21c35d0 --- /dev/null +++ b/examples/modern_http_server.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "rackup" +require "json" +require "logger" + +# Example server for the 2026-07-28 modern lifecycle (SEP-2575). +# +# The modern lifecycle is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. +# Clients probe the server with `server/discover` and then send self-contained requests that carry +# a `_meta` envelope (protocol version, client info, capabilities) plus `MCP-Protocol-Version` / `Mcp-Method` headers. +# +# The same server still accepts the legacy `initialize` flow: the transport routes each request to +# the legacy or modern lifecycle by its `MCP-Protocol-Version` header, so no constructor flag is needed. +server = MCP::Server.new( + name: "modern_lifecycle_server", + version: "1.0.0", + instructions: "Example server demonstrating the 2026-07-28 modern lifecycle.", + # SEP-2549 cache hints, advertised in `server/discover` and stamped on cacheable results. + # The defaults are 0 / "private"; non-default values are used here so they are visible + # in the example output. + ttl_ms: 60_000, + cache_scope: "public", +) + +server.define_tool( + name: "greet", + description: "Greets a name; a plain single round-trip tool", + input_schema: { properties: { name: { type: "string" } }, required: ["name"] }, +) do |name:| + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{name}!" }]) +end + +# A multi round-trip tool (SEP-2322). The first round returns an `input_required` result asking +# the client to answer an embedded `elicitation/create` request; the client re-issues the call with +# `inputResponses` plus the echoed opaque `requestState`, and the handler re-runs from the start +# and reads the answer via `server_context`. +# Over a legacy connection the default `input_required_legacy_shim` fulfills the same result through +# a real server-to-client elicitation request instead. +server.define_tool( + name: "deploy", + description: "Deploys an app; asks which environment via a multi round-trip elicitation (SEP-2322)", + input_schema: { properties: { app: { type: "string" } }, required: ["app"] }, +) do |app:, server_context:| + answer = server_context.input_response("environment") + + if answer.nil? + MCP::Server::InputRequiredResult.new( + input_requests: { + "environment" => { + method: "elicitation/create", + params: { + message: "Which environment should #{app} deploy to?", + requestedSchema: { + type: "object", + properties: { + environment: { type: "string", title: "Environment", default: "staging" }, + }, + required: ["environment"], + }, + }, + }, + }, + request_state: JSON.generate(app: app), + ) + else + # Transports parse JSON with `symbolize_names: true`, so answers normally arrive symbol-keyed; + # the string fallback covers custom transports. + action = answer[:action] || answer["action"] + + if action == "accept" + environment = answer.dig(:content, :environment) || answer.dig("content", "environment") + MCP::Tool::Response.new([{ type: "text", text: "Deployed #{app} to #{environment}" }]) + else + MCP::Tool::Response.new([{ type: "text", text: "Deployment of #{app} was cancelled (#{action})" }]) + end + end +end + +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app. +# See http_server.rb for a CORS setup for browser-based clients. +rack_app = Rack::Builder.new do + use(Rack::CommonLogger, Logger.new($stdout)) + use(Rack::ShowExceptions) + + run(transport) +end + +puts <<~MESSAGE + === MCP Modern Lifecycle Server (2026-07-28) === + + Starting server on http://localhost:9494 + + Run the SDK client against it: + ruby examples/modern_http_client.rb + + Or walk the modern lifecycle with cURL: + + 1. Probe capabilities (sessionless, works without any protocol headers): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}' + + 2. Modern tools/list (the MCP-Protocol-Version header selects the era, the + Mcp-Method header mirrors the method, and params._meta carries the + SEP-2575 envelope): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/list" \\ + --json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' + + 3. Modern tools/call (name-bearing methods additionally mirror the name in + the Mcp-Name header; no Mcp-Session-Id is ever sent): + curl -s http://localhost:9494 \\ + -H "Accept: application/json, text/event-stream" \\ + -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/call" -H "Mcp-Name: greet" \\ + --json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' + + Press Ctrl+C to stop the server +MESSAGE + +Rackup::Handler.get("puma").run(rack_app, Port: 9494, Host: "localhost") diff --git a/examples/rails/README.md b/examples/rails/README.md index 6a599c3c..eaee5313 100644 --- a/examples/rails/README.md +++ b/examples/rails/README.md @@ -36,7 +36,7 @@ per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` line ```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-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \ + --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') ``` diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index 79e3f384..ab7aeed2 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -18,8 +18,11 @@ def create_logger logger end -# The SDK does not yet implement the optional GET SSE stream, so this example -# uses MCP::Client for JSON-RPC requests and raw Net::HTTP for the event stream. +# 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) @@ -64,6 +67,42 @@ def print_response(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 @@ -76,11 +115,17 @@ def main 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 @@ -104,8 +149,6 @@ def main sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) } sleep(1) - # Once the optional SSE stream is active, POST requests may receive only a - # 202 ACK while the actual JSON-RPC response is delivered over SSE. loop do puts <<~MENU.chomp @@ -126,12 +169,15 @@ def main print("Enter delay in seconds (0 for immediate): ") delay = gets.chomp.to_f - puts "=== Calling tool: notification_tool ===" - response = client.call_tool( - tool: notification_tool, + 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, ) - print_response(response) else puts "notification_tool not available" end diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index a20d28e6..f122296e 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -13,14 +13,16 @@ "[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" end -# Tool that returns a response that will be sent via SSE if a stream is active +# Tool that emits `notifications/progress` events, delivered over the SSE stream. +# The client must request them by sending a `progressToken` in the request `_meta`. class NotificationTool < MCP::Tool tool_name "notification_tool" - description "Returns a notification message that will be sent via SSE if stream is active" + description "Sends the message back as progress notifications via SSE, then returns a summary" input_schema( properties: { message: { type: "string", description: "Message to send via SSE" }, - delay: { type: "number", description: "Delay in seconds before returning (optional)" }, + steps: { type: "number", description: "Number of progress notifications to send (default: 3)" }, + delay: { type: "number", description: "Delay in seconds between notifications (optional)" }, }, required: ["message"], ) @@ -28,14 +30,19 @@ class NotificationTool < MCP::Tool class << self attr_accessor :logger - def call(message:, delay: 0) - sleep(delay) if delay > 0 + def call(message:, server_context:, steps: 3, delay: 0) + steps = steps.to_i.clamp(1, 10) - logger&.info("Returning notification message: #{message}") + steps.times do |i| + sleep(delay) if delay > 0 + + logger&.info("Reporting progress #{i + 1}/#{steps}: #{message}") + server_context.report_progress(i + 1, total: steps, message: message) + end MCP::Tool::Response.new([{ type: "text", - text: "Notification: #{message} (timestamp: #{Time.now.iso8601})", + text: "Sent #{steps} progress notifications for: #{message}", }]) end end @@ -44,6 +51,7 @@ def call(message:, delay: 0) # Create the server server = MCP::Server.new( name: "sse_test_server", + version: "1.0.0", tools: [NotificationTool], prompts: [], resources: [], @@ -149,29 +157,34 @@ def call(env) Starting server on http://localhost:9393 Available Tools: - 1. NotificationTool - Returns messages that are sent via SSE when stream is active" + 1. notification_tool - Sends the message back as progress notifications via SSE 2. echo - Simple echo tool Testing SSE: 1. Initialize session: curl -i http://localhost:9393 \\ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}' + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}' - 2. Connect SSE stream (use the session ID from step 1):" + 2. Connect SSE stream (use the session ID from step 1): curl -i -N -H "Mcp-Session-Id: YOUR_SESSION_ID" http://localhost:9393 3. In another terminal, test tools (responses will be sent via SSE if stream is active): Echo tool: curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ + -H "Accept: application/json, text/event-stream" \\ --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"echo","arguments":{"message":"Hello SSE!"}}}' - Notification tool (with 2 second delay): + Notification tool (progress notifications require a progressToken in _meta): curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ - --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}' + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!","delay":1},"_meta":{"progressToken":"curl-progress"}}}' - Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return 202 Accepted with no body. + Note: Each POST is answered with an SSE stream of its own; request-scoped notifications + (such as the progress events above) appear on it before the final response. + The standalone GET stream carries server-initiated messages that are not tied to a request. Press Ctrl+C to stop the server MESSAGE