11# frozen_string_literal: true
22
3+ require "mcp"
4+ require "mcp/client"
5+ require "mcp/client/http"
6+ require "mcp/client/tool"
37require "net/http"
48require "uri"
59require "json"
610require "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
4422end
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
4727def 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
8157rescue Interrupt
82- logger . info ( "SSE connection interrupted by user " )
58+ logger . info ( "SSE connection interrupted" )
8359rescue => e
8460 logger . error ( "SSE connection error: #{ e . message } " )
8561end
8662
87- # Main client flow
8863def 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 " ) )
202178end
203179
204- # Run the client
205180if __FILE__ == $PROGRAM_NAME
206181 main
207182end
0 commit comments