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.
This commit is contained in:
parent
da4ce54075
commit
bfc6e551a3
8 changed files with 501 additions and 51 deletions
|
|
@ -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:**
|
||||
|
|
@ -129,6 +129,7 @@ An interactive client that connects to the SSE stream and provides a menu-driven
|
|||
```
|
||||
|
||||
2. Run the SSE test client in another terminal:
|
||||
|
||||
```console
|
||||
$ ruby examples/streamable_http_client.rb
|
||||
```
|
||||
|
|
@ -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}}}'
|
||||
```
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ require "net/http"
|
|||
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 @@ class MCPHTTPClient
|
|||
|
||||
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 @@ class MCPHTTPClient
|
|||
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
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ end
|
|||
# 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 @@ 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 --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.
|
||||
|
|
|
|||
133
examples/modern_http_client.rb
Normal file
133
examples/modern_http_client.rb
Normal file
|
|
@ -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
|
||||
126
examples/modern_http_server.rb
Normal file
126
examples/modern_http_server.rb
Normal file
|
|
@ -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")
|
||||
|
|
@ -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')
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,14 +13,16 @@ sse_logger.formatter = proc do |severity, datetime, _progname, msg|
|
|||
"[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)
|
||||
def call(message:, server_context:, steps: 3, delay: 0)
|
||||
steps = steps.to_i.clamp(1, 10)
|
||||
|
||||
steps.times do |i|
|
||||
sleep(delay) if delay > 0
|
||||
|
||||
logger&.info("Returning notification message: #{message}")
|
||||
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 @@ end
|
|||
# Create the server
|
||||
server = MCP::Server.new(
|
||||
name: "sse_test_server",
|
||||
version: "1.0.0",
|
||||
tools: [NotificationTool],
|
||||
prompts: [],
|
||||
resources: [],
|
||||
|
|
@ -149,29 +157,34 @@ puts <<~MESSAGE
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue