Skip to content

Commit 0c3b676

Browse files
keiskuclaude
authored andcommitted
Add MCP Streamable HTTP specification support for the client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0d700d7 commit 0c3b676

File tree

8 files changed

+782
-428
lines changed

8 files changed

+782
-428
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ gem "rubocop-minitest", require: false
1010
gem "rubocop-rake", require: false
1111
gem "rubocop-shopify", ">= 2.18", require: false if RUBY_VERSION >= "3.1"
1212

13+
gem "event_stream_parser", ">= 1.0"
1314
gem "puma", ">= 5.0.0"
1415
gem "rack-cors"
1516
gem "rackup", ">= 2.1.0"

examples/streamable_http_client.rb

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

3+
require "mcp"
4+
require "mcp/client"
5+
require "mcp/client/http"
6+
require "mcp/client/tool"
37
require "net/http"
48
require "uri"
59
require "json"
610
require "logger"
11+
require "event_stream_parser"
712

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"
13+
SERVER_URL = "http://localhost:9393"
1714

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 }
15+
# Logger for client operations
16+
def create_logger
17+
logger = Logger.new($stdout)
18+
logger.formatter = proc do |severity, datetime, _progname, msg|
19+
"[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
20+
end
21+
logger
4422
end
4523

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

@@ -59,17 +39,13 @@ def connect_sse(session_id, logger)
5939
if response.code == "200"
6040
logger.info("SSE stream connected successfully")
6141

42+
parser = EventStreamParser::Parser.new
6243
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}")
44+
parser.feed(chunk) do |type, data, _id|
45+
if type.empty?
46+
logger.info("SSE event: #{data}")
47+
else
48+
logger.info("SSE event (#{type}): #{data}")
7349
end
7450
end
7551
end
@@ -79,129 +55,128 @@ def connect_sse(session_id, logger)
7955
end
8056
end
8157
rescue Interrupt
82-
logger.info("SSE connection interrupted by user")
58+
logger.info("SSE connection interrupted")
8359
rescue => e
8460
logger.error("SSE connection error: #{e.message}")
8561
end
8662

87-
# Main client flow
8863
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:#{" "}
64+
logger = create_logger
65+
66+
puts <<~MESSAGE
67+
MCP Streamable HTTP Client
68+
Make sure the server is running (ruby examples/streamable_http_server.rb)
69+
#{"=" * 60}
70+
MESSAGE
71+
72+
# Initialize SDK client
73+
transport = MCP::Client::HTTP.new(url: SERVER_URL)
74+
client = MCP::Client.new(transport: transport)
75+
76+
begin
77+
# Initialize session using SDK
78+
puts "=== Initializing session ==="
79+
init_response = client.connect(
80+
client_info: { name: "streamable-http-client", version: "1.0" },
81+
)
82+
puts <<~MESSAGE
83+
ID: #{client.session_id}
84+
Version: #{client.protocol_version}
85+
Server: #{init_response.dig("result", "serverInfo")}
14986
MESSAGE
15087

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

204-
# Run the client
205180
if __FILE__ == $PROGRAM_NAME
206181
main
207182
end

examples/streamable_http_server.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

3-
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
3+
# Usage: bundle exec ruby examples/streamable_http_server.rb
44
require "mcp"
55
require "rack/cors"
66
require "rackup"
@@ -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)