Skip to content

Commit a499248

Browse files
keiskuatesgoralclaude
committed
Add MCP Streamable HTTP specification support for the client
Implements the MCP Streamable HTTP specification for the Ruby SDK client: SSE response parsing via event_stream_parser, session management, 202 Accepted handling, DELETE for session termination, and protocol version header. Co-Authored-By: Ates Goral <ates@magnetiq.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7b5533c commit a499248

9 files changed

Lines changed: 736 additions & 435 deletions

File tree

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ gem "yard", "~> 0.9"
2323
gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1"
2424

2525
group :test do
26+
gem "event_stream_parser", ">= 1.0"
2627
gem "faraday", ">= 2.0"
2728
gem "minitest", "~> 5.1", require: false
2829
gem "mocha"

docs/building-clients.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,26 @@ stdio_transport.close
5151

5252
## HTTP Transport
5353

54-
Use `MCP::Client::HTTP` to interact with MCP servers over HTTP. Requires the `faraday` gem:
54+
Use `MCP::Client::HTTP` to interact with MCP servers over [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http). Requires the `faraday` gem, plus `event_stream_parser` if the server uses SSE responses:
5555

5656
```ruby
5757
gem 'mcp'
5858
gem 'faraday', '>= 2.0'
59+
gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
5960
```
6061

62+
Call `MCP::Client#connect` explicitly to perform the MCP initialization handshake. The transport tracks the session ID and protocol version from the response and includes them on subsequent requests. Call `MCP::Client#close` to terminate the session via DELETE:
63+
6164
```ruby
6265
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
6366
client = MCP::Client.new(transport: http_transport)
6467

68+
client.connect(client_info: { name: "my-client", version: "1.0" })
69+
70+
client.session_id # => "abc123..."
71+
client.protocol_version # => "2025-11-25"
72+
client.connected? # => true
73+
6574
tools = client.tools
6675
tools.each do |tool|
6776
puts "Tool: #{tool.name} - #{tool.description}"
@@ -71,6 +80,8 @@ response = client.call_tool(
7180
tool: tools.first,
7281
arguments: { message: "Hello, world!" }
7382
)
83+
84+
client.close
7485
```
7586

7687
### Authorization

examples/streamable_http_client.rb

Lines changed: 132 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,30 @@
11
# frozen_string_literal: true
22

3+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4+
require "mcp"
5+
require "mcp/client"
6+
require "mcp/client/http"
7+
require "mcp/client/tool"
38
require "net/http"
49
require "uri"
510
require "json"
611
require "logger"
12+
require "event_stream_parser"
713

8-
# Logger for client operations
9-
logger = Logger.new($stdout)
10-
logger.formatter = proc do |severity, datetime, _progname, msg|
11-
"[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
12-
end
13-
14-
# Server configuration
15-
SERVER_URL = "http://localhost:9393/mcp"
16-
PROTOCOL_VERSION = "2024-11-05"
14+
SERVER_URL = "http://localhost:9393"
1715

18-
# Helper method to make JSON-RPC requests
19-
def make_request(session_id, method, params = {}, id = nil)
20-
uri = URI(SERVER_URL)
21-
http = Net::HTTP.new(uri.host, uri.port)
22-
23-
request = Net::HTTP::Post.new(uri)
24-
request["Content-Type"] = "application/json"
25-
request["Mcp-Session-Id"] = session_id if session_id
26-
27-
body = {
28-
jsonrpc: "2.0",
29-
method: method,
30-
params: params,
31-
id: id || SecureRandom.uuid,
32-
}
33-
34-
request.body = body.to_json
35-
response = http.request(request)
36-
37-
{
38-
status: response.code,
39-
headers: response.to_hash,
40-
body: JSON.parse(response.body),
41-
}
42-
rescue => e
43-
{ error: e.message }
16+
# Logger for client operations
17+
def create_logger
18+
logger = Logger.new($stdout)
19+
logger.formatter = proc do |severity, datetime, _progname, msg|
20+
"[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
21+
end
22+
logger
4423
end
4524

46-
# Connect to SSE stream
25+
# Connect to SSE stream for real-time notifications
26+
# The SDK doesn't support HTTP GET for SSE streaming yet, so we use raw Net::HTTP
27+
# See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
4728
def connect_sse(session_id, logger)
4829
uri = URI(SERVER_URL)
4930

@@ -59,17 +40,13 @@ def connect_sse(session_id, logger)
5940
if response.code == "200"
6041
logger.info("SSE stream connected successfully")
6142

43+
parser = EventStreamParser::Parser.new
6244
response.read_body do |chunk|
63-
chunk.split("\n").each do |line|
64-
if line.start_with?("data: ")
65-
data = line[6..-1]
66-
begin
67-
logger.info("SSE data: #{data}")
68-
rescue JSON::ParserError
69-
logger.debug("Non-JSON SSE data: #{data}")
70-
end
71-
elsif line.start_with?(": ")
72-
logger.debug("SSE keepalive received: #{line}")
45+
parser.feed(chunk) do |type, data, _id|
46+
if type.empty?
47+
logger.info("SSE event: #{data}")
48+
else
49+
logger.info("SSE event (#{type}): #{data}")
7350
end
7451
end
7552
end
@@ -79,129 +56,128 @@ def connect_sse(session_id, logger)
7956
end
8057
end
8158
rescue Interrupt
82-
logger.info("SSE connection interrupted by user")
59+
logger.info("SSE connection interrupted")
8360
rescue => e
8461
logger.error("SSE connection error: #{e.message}")
8562
end
8663

87-
# Main client flow
8864
def main
89-
logger = Logger.new($stdout)
90-
logger.formatter = proc do |severity, datetime, _progname, msg|
91-
"[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
92-
end
93-
94-
puts "=== MCP SSE Test Client ==="
95-
96-
# Step 1: Initialize session
97-
logger.info("Initializing session...")
98-
99-
init_response = make_request(
100-
nil,
101-
"initialize",
102-
{
103-
protocolVersion: PROTOCOL_VERSION,
104-
capabilities: {},
105-
clientInfo: {
106-
name: "sse-test-client",
107-
version: "1.0",
108-
},
109-
},
110-
"init-1",
111-
)
112-
113-
if init_response[:error]
114-
logger.error("Failed to initialize: #{init_response[:error]}")
115-
exit(1)
116-
end
117-
118-
session_id = init_response[:headers]["mcp-session-id"]&.first
119-
120-
if session_id.nil?
121-
logger.error("No session ID received")
122-
exit(1)
123-
end
124-
125-
if init_response[:body].dig("result", "capabilities", "logging")
126-
make_request(session_id, "logging/setLevel", { level: "info" })
127-
end
128-
129-
logger.info("Session initialized: #{session_id}")
130-
logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}")
131-
132-
# Step 2: Start SSE connection in a separate thread
133-
sse_thread = Thread.new { connect_sse(session_id, logger) }
134-
135-
# Give SSE time to connect
136-
sleep(1)
137-
138-
# Step 3: Interactive menu
139-
loop do
140-
puts <<~MESSAGE.chomp
141-
142-
=== Available Actions ===
143-
1. Send custom notification
144-
2. Test echo
145-
3. List tools
146-
0. Exit
147-
148-
Choose an action:#{" "}
65+
logger = create_logger
66+
67+
puts <<~MESSAGE
68+
MCP Streamable HTTP Client
69+
Make sure the server is running (ruby examples/streamable_http_server.rb)
70+
#{"=" * 60}
71+
MESSAGE
72+
73+
# Initialize SDK client
74+
transport = MCP::Client::HTTP.new(url: SERVER_URL)
75+
client = MCP::Client.new(transport: transport)
76+
77+
begin
78+
# Initialize session using SDK
79+
puts "=== Initializing session ==="
80+
init_response = client.connect(
81+
client_info: { name: "streamable-http-client", version: "1.0" },
82+
)
83+
puts <<~MESSAGE
84+
ID: #{client.session_id}
85+
Version: #{client.protocol_version}
86+
Server: #{init_response.dig("result", "serverInfo")}
14987
MESSAGE
15088

151-
choice = gets.chomp
152-
153-
case choice
154-
when "1"
155-
print("Enter notification message: ")
156-
message = gets.chomp
157-
print("Enter delay in seconds (0 for immediate): ")
158-
delay = gets.chomp.to_f
159-
160-
response = make_request(
161-
session_id,
162-
"tools/call",
163-
{
164-
name: "notification_tool",
165-
arguments: {
166-
message: message,
167-
delay: delay,
168-
},
169-
},
170-
)
171-
if response[:body]["accepted"]
172-
logger.info("Notification sent successfully")
89+
# Get available tools BEFORE establishing SSE connection
90+
# (Once SSE is active, server sends responses via SSE stream, not POST response)
91+
puts "=== Listing tools ==="
92+
tools = client.tools
93+
tools.each { |t| puts " - #{t.name}: #{t.description}" }
94+
95+
echo_tool = tools.find { |t| t.name == "echo" }
96+
notification_tool = tools.find { |t| t.name == "notification_tool" }
97+
98+
# Start SSE connection in a separate thread (uses raw HTTP)
99+
# Note: After this, server responses will be sent via SSE, not POST
100+
sse_thread = Thread.new { connect_sse(client.session_id, logger) }
101+
102+
# Give SSE time to connect
103+
sleep(1)
104+
105+
# Interactive menu
106+
loop do
107+
puts <<~MENU.chomp
108+
109+
=== Available Actions ===
110+
1. Send notification (triggers SSE event)
111+
2. Echo message
112+
3. List tools
113+
0. Exit
114+
115+
Choose an action:#{" "}
116+
MENU
117+
118+
choice = gets.chomp
119+
120+
case choice
121+
when "1"
122+
if notification_tool
123+
print("Enter notification message: ")
124+
message = gets.chomp
125+
print("Enter delay in seconds (0 for immediate): ")
126+
delay = gets.chomp.to_f
127+
128+
puts "=== Calling tool: notification_tool ==="
129+
response = client.call_tool(
130+
tool: notification_tool,
131+
arguments: { message: message, delay: delay },
132+
)
133+
puts "Response: #{JSON.pretty_generate(response)}"
134+
else
135+
puts "notification_tool not available"
136+
end
137+
when "2"
138+
if echo_tool
139+
print("Enter message to echo: ")
140+
message = gets.chomp
141+
142+
puts "=== Calling tool: echo ==="
143+
response = client.call_tool(tool: echo_tool, arguments: { message: message })
144+
puts "Response: #{JSON.pretty_generate(response)}"
145+
else
146+
puts "echo tool not available"
147+
end
148+
when "3"
149+
puts "=== Listing tools ==="
150+
puts "(Note: Response will appear in SSE stream when active)"
151+
client.tools.each do |tool|
152+
puts " - #{tool.name}: #{tool.description}"
153+
end
154+
when "0"
155+
logger.info("Exiting...")
156+
break
173157
else
174-
logger.error("Error: #{response[:body]["error"]}")
158+
puts "Invalid choice"
175159
end
176-
when "2"
177-
print("Enter message to echo: ")
178-
message = gets.chomp
179-
make_request(session_id, "tools/call", { name: "echo", arguments: { message: message } })
180-
when "3"
181-
make_request(session_id, "tools/list")
182-
when "0"
183-
logger.info("Exiting...")
184-
break
185-
else
186-
puts "Invalid choice"
187160
end
161+
rescue MCP::Client::SessionExpiredError => e
162+
logger.error("Session expired: #{e.message}")
163+
rescue MCP::Client::RequestHandlerError => e
164+
logger.error("Request error: #{e.message}")
165+
rescue Interrupt
166+
logger.info("Client interrupted")
167+
rescue => e
168+
logger.error("Error: #{e.message}")
169+
logger.error(e.backtrace.first(5).join("\n"))
170+
ensure
171+
# Clean up SSE thread
172+
sse_thread.kill if sse_thread&.alive?
173+
174+
# Close session using SDK
175+
puts "=== Closing session ==="
176+
client.close
177+
puts "Session closed"
188178
end
189-
190-
# Clean up
191-
sse_thread.kill if sse_thread.alive?
192-
193-
# Close session
194-
logger.info("Closing session...")
195-
make_request(session_id, "close")
196-
logger.info("Session closed")
197-
rescue Interrupt
198-
logger.info("Client interrupted by user")
199-
rescue => e
200-
logger.error("Client error: #{e.message}")
201-
logger.error(e.backtrace.join("\n"))
202179
end
203180

204-
# Run the client
205181
if __FILE__ == $PROGRAM_NAME
206182
main
207183
end

examples/streamable_http_server.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class << self
3131
def call(message:, delay: 0)
3232
sleep(delay) if delay > 0
3333

34-
logger&.info("Returning notification message: #{message}")
34+
logger.info("Returning notification message: #{message}")
3535

3636
MCP::Tool::Response.new([{
3737
type: "text",

0 commit comments

Comments
 (0)