Skip to content

Commit 09637b6

Browse files
committed
Add MCP Streamable HTTP specification support for the client
1 parent b92dbe4 commit 09637b6

7 files changed

Lines changed: 784 additions & 400 deletions

File tree

examples/streamable_http_client.rb

Lines changed: 127 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,35 @@
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"
711

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

14-
# Server configuration
15-
SERVER_URL = "http://localhost:9393/mcp"
16-
PROTOCOL_VERSION = "2024-11-05"
17-
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 }
14+
# Logger for client operations
15+
def create_logger
16+
logger = Logger.new($stdout)
17+
logger.formatter = proc do |severity, datetime, _progname, msg|
18+
"[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
19+
end
20+
logger
4421
end
4522

46-
# Connect to SSE stream
23+
# Connect to SSE stream for real-time notifications
24+
# Note: The SDK doesn't support SSE streaming yet, so we use raw Net::HTTP
4725
def connect_sse(session_id, logger)
4826
uri = URI(SERVER_URL)
4927

5028
logger.info("Connecting to SSE stream...")
5129

5230
Net::HTTP.start(uri.host, uri.port) do |http|
5331
request = Net::HTTP::Get.new(uri)
54-
request["Mcp-Session-Id"] = session_id
32+
request["MCP-Session-Id"] = session_id
5533
request["Accept"] = "text/event-stream"
5634
request["Cache-Control"] = "no-cache"
5735

@@ -62,14 +40,10 @@ def connect_sse(session_id, logger)
6240
response.read_body do |chunk|
6341
chunk.split("\n").each do |line|
6442
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
43+
data = line[6..]
44+
logger.info("SSE event: #{data}")
7145
elsif line.start_with?(": ")
72-
logger.debug("SSE keepalive received: #{line}")
46+
logger.debug("SSE keepalive: #{line}")
7347
end
7448
end
7549
end
@@ -79,125 +53,126 @@ def connect_sse(session_id, logger)
7953
end
8054
end
8155
rescue Interrupt
82-
logger.info("SSE connection interrupted by user")
56+
logger.info("SSE connection interrupted")
8357
rescue => e
8458
logger.error("SSE connection error: #{e.message}")
8559
end
8660

87-
# Main client flow
8861
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-
logger.info("Session initialized: #{session_id}")
126-
logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}")
127-
128-
# Step 2: Start SSE connection in a separate thread
129-
sse_thread = Thread.new { connect_sse(session_id, logger) }
130-
131-
# Give SSE time to connect
132-
sleep(1)
133-
134-
# Step 3: Interactive menu
135-
loop do
136-
puts <<~MESSAGE.chomp
137-
138-
=== Available Actions ===
139-
1. Send custom notification
140-
2. Test echo
141-
3. List tools
142-
0. Exit
143-
144-
Choose an action:#{" "}
145-
MESSAGE
146-
147-
choice = gets.chomp
148-
149-
case choice
150-
when "1"
151-
print("Enter notification message: ")
152-
message = gets.chomp
153-
print("Enter delay in seconds (0 for immediate): ")
154-
delay = gets.chomp.to_f
155-
156-
response = make_request(
157-
session_id,
158-
"tools/call",
159-
{
160-
name: "notification_tool",
161-
arguments: {
162-
message: message,
163-
delay: delay,
164-
},
165-
},
166-
)
167-
if response[:body]["accepted"]
168-
logger.info("Notification sent successfully")
62+
logger = create_logger
63+
64+
puts <<~MESSAGE
65+
MCP Streamable HTTP Client (SDK + SSE)
66+
Make sure the server is running (ruby examples/streamable_http_server.rb)
67+
#{"=" * 60}
68+
MESSAGE
69+
70+
# Initialize SDK client
71+
transport = MCP::Client::HTTP.new(url: SERVER_URL)
72+
client = MCP::Client.new(transport: transport)
73+
74+
begin
75+
# Initialize session using SDK
76+
puts "=== Initializing session ==="
77+
init_response = client.connect(
78+
client_info: { name: "streamable-http-client", version: "1.0" },
79+
)
80+
puts "Session ID: #{client.session_id}"
81+
puts "Protocol Version: #{client.protocol_version}"
82+
puts "Server Info: #{init_response.dig("result", "serverInfo")}"
83+
84+
# Get available tools BEFORE establishing SSE connection
85+
# (Once SSE is active, server sends responses via SSE stream, not POST response)
86+
puts "=== Listing tools ==="
87+
tools = client.tools
88+
tools.each { |t| puts " - #{t.name}: #{t.description}" }
89+
90+
echo_tool = tools.find { |t| t.name == "echo" }
91+
notification_tool = tools.find { |t| t.name == "notification_tool" }
92+
93+
# Start SSE connection in a separate thread (uses raw HTTP)
94+
# Note: After this, server responses will be sent via SSE, not POST
95+
sse_thread = Thread.new { connect_sse(client.session_id, logger) }
96+
97+
# Give SSE time to connect
98+
sleep(1)
99+
100+
# Interactive menu
101+
loop do
102+
puts <<~MENU.chomp
103+
104+
=== Available Actions ===
105+
1. Send notification (triggers SSE event)
106+
2. Echo message
107+
3. List tools
108+
0. Exit
109+
110+
Choose an action:#{" "}
111+
MENU
112+
113+
choice = gets&.chomp
114+
115+
case choice
116+
when "1"
117+
if notification_tool
118+
print("Enter notification message: ")
119+
message = gets&.chomp || "Test"
120+
print("Enter delay in seconds (0 for immediate): ")
121+
delay = (gets&.chomp || "0").to_f
122+
123+
puts "=== Calling tool: notification_tool ==="
124+
response = client.call_tool(
125+
tool: notification_tool,
126+
arguments: { message: message, delay: delay },
127+
)
128+
puts "Response: #{JSON.pretty_generate(response)}"
129+
else
130+
puts "notification_tool not available"
131+
end
132+
when "2"
133+
if echo_tool
134+
print("Enter message to echo: ")
135+
message = gets&.chomp || "Hello"
136+
137+
puts "=== Calling tool: echo ==="
138+
response = client.call_tool(tool: echo_tool, arguments: { message: message })
139+
puts "Response: #{JSON.pretty_generate(response)}"
140+
else
141+
puts "echo tool not available"
142+
end
143+
when "3"
144+
puts "=== Listing tools ==="
145+
puts "(Note: Response will appear in SSE stream when active)"
146+
client.tools.each do |tool|
147+
puts " - #{tool.name}: #{tool.description}"
148+
end
149+
when "0", nil
150+
logger.info("Exiting...")
151+
break
169152
else
170-
logger.error("Error: #{response[:body]["error"]}")
153+
puts "Invalid choice"
171154
end
172-
when "2"
173-
print("Enter message to echo: ")
174-
message = gets.chomp
175-
make_request(session_id, "tools/call", { name: "echo", arguments: { message: message } })
176-
when "3"
177-
make_request(session_id, "tools/list")
178-
when "0"
179-
logger.info("Exiting...")
180-
break
181-
else
182-
puts "Invalid choice"
183155
end
156+
rescue MCP::Client::SessionExpiredError => e
157+
logger.error("Session expired: #{e.message}")
158+
rescue MCP::Client::RequestHandlerError => e
159+
logger.error("Request error: #{e.message}")
160+
rescue Interrupt
161+
logger.info("Client interrupted")
162+
rescue => e
163+
logger.error("Error: #{e.message}")
164+
logger.error(e.backtrace.first(5).join("\n"))
165+
ensure
166+
# Clean up SSE thread
167+
sse_thread&.kill if sse_thread&.alive?
168+
169+
# Close session using SDK
170+
puts "=== Closing session ==="
171+
client.close
172+
puts "Session closed"
184173
end
185-
186-
# Clean up
187-
sse_thread.kill if sse_thread.alive?
188-
189-
# Close session
190-
logger.info("Closing session...")
191-
make_request(session_id, "close")
192-
logger.info("Session closed")
193-
rescue Interrupt
194-
logger.info("Client interrupted by user")
195-
rescue => e
196-
logger.error("Client error: #{e.message}")
197-
logger.error(e.backtrace.join("\n"))
198174
end
199175

200-
# Run the client
201176
if __FILE__ == $PROGRAM_NAME
202177
main
203178
end

examples/streamable_http_server.rb

Lines changed: 1 addition & 1 deletion
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 "rackup"
66
require "json"

0 commit comments

Comments
 (0)