ruby-mcp-client 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp_client/json_rpc_common'
4
+
5
+ module MCPClient
6
+ class ServerSSE
7
+ # JSON-RPC request/notification plumbing for SSE transport
8
+ module JsonRpcTransport
9
+ include JsonRpcCommon
10
+ # Generic JSON-RPC request: send method with params and return result
11
+ # @param method [String] JSON-RPC method name
12
+ # @param params [Hash] parameters for the request
13
+ # @return [Object] result from JSON-RPC response
14
+ # @raise [MCPClient::Errors::ConnectionError] if connection is not active or reconnect fails
15
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
16
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
17
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
18
+ def rpc_request(method, params = {})
19
+ ensure_initialized
20
+
21
+ with_retry do
22
+ request_id = @mutex.synchronize { @request_id += 1 }
23
+ request = build_jsonrpc_request(method, params, request_id)
24
+ send_jsonrpc_request(request)
25
+ end
26
+ end
27
+
28
+ # Send a JSON-RPC notification (no response expected)
29
+ # @param method [String] JSON-RPC method name
30
+ # @param params [Hash] parameters for the notification
31
+ # @return [void]
32
+ def rpc_notify(method, params = {})
33
+ ensure_initialized
34
+ notif = build_jsonrpc_notification(method, params)
35
+ post_json_rpc_request(notif)
36
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
37
+ raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ # Ensure SSE initialization handshake has been performed.
43
+ # Attempts to reconnect and reinitialize if the SSE connection is not active.
44
+ #
45
+ # @raise [MCPClient::Errors::ConnectionError] if reconnect or initialization fails
46
+ def ensure_initialized
47
+ if !@connection_established || !@sse_connected
48
+ @logger.debug('Connection not active, attempting to reconnect before RPC request')
49
+ cleanup
50
+ connect
51
+ perform_initialize
52
+ @initialized = true
53
+ return
54
+ end
55
+
56
+ return if @initialized
57
+
58
+ perform_initialize
59
+ @initialized = true
60
+ end
61
+
62
+ # Perform JSON-RPC initialize handshake with the MCP server
63
+ # @return [void]
64
+ def perform_initialize
65
+ request_id = @mutex.synchronize { @request_id += 1 }
66
+ json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
67
+ @logger.debug("Performing initialize RPC: #{json_rpc_request}")
68
+ result = send_jsonrpc_request(json_rpc_request)
69
+ return unless result.is_a?(Hash)
70
+
71
+ @server_info = result['serverInfo'] if result.key?('serverInfo')
72
+ @capabilities = result['capabilities'] if result.key?('capabilities')
73
+ end
74
+
75
+ # Send a JSON-RPC request to the server and wait for result
76
+ # @param request [Hash] the JSON-RPC request
77
+ # @return [Hash] the result of the request
78
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
79
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
80
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
81
+ def send_jsonrpc_request(request)
82
+ @logger.debug("Sending JSON-RPC request: #{request.to_json}")
83
+ record_activity
84
+
85
+ begin
86
+ response = post_json_rpc_request(request)
87
+
88
+ if @use_sse
89
+ wait_for_sse_result(request)
90
+ else
91
+ parse_direct_response(response)
92
+ end
93
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
94
+ raise
95
+ rescue JSON::ParserError => e
96
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
97
+ rescue Errno::ECONNREFUSED => e
98
+ raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
99
+ rescue StandardError => e
100
+ method_name = request[:method] || request['method']
101
+ raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
102
+ end
103
+ end
104
+
105
+ # Post a JSON-RPC request to the server
106
+ # @param request [Hash] the JSON-RPC request
107
+ # @return [Faraday::Response] the HTTP response
108
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
109
+ def post_json_rpc_request(request)
110
+ uri = URI.parse(@base_url)
111
+ base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
112
+ rpc_ep = @mutex.synchronize { @rpc_endpoint }
113
+
114
+ @rpc_conn ||= create_json_rpc_connection(base)
115
+
116
+ begin
117
+ response = send_http_request(@rpc_conn, rpc_ep, request)
118
+ record_activity
119
+
120
+ unless response.success?
121
+ raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
122
+ end
123
+
124
+ response
125
+ rescue Faraday::ConnectionFailed => e
126
+ raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
127
+ end
128
+ end
129
+
130
+ # Create a Faraday connection for JSON-RPC
131
+ # @param base_url [String] the base URL for the connection
132
+ # @return [Faraday::Connection] the configured connection
133
+ def create_json_rpc_connection(base_url)
134
+ Faraday.new(url: base_url) do |f|
135
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
136
+ f.options.open_timeout = @read_timeout
137
+ f.options.timeout = @read_timeout
138
+ f.adapter Faraday.default_adapter
139
+ end
140
+ end
141
+
142
+ # Send an HTTP request with the proper headers and body
143
+ # @param conn [Faraday::Connection] the connection to use
144
+ # @param endpoint [String] the endpoint to post to
145
+ # @param request [Hash] the request data
146
+ # @return [Faraday::Response] the HTTP response
147
+ def send_http_request(conn, endpoint, request)
148
+ response = conn.post(endpoint) do |req|
149
+ req.headers['Content-Type'] = 'application/json'
150
+ req.headers['Accept'] = 'application/json'
151
+ (@headers.dup.tap do |h|
152
+ h.delete('Accept')
153
+ h.delete('Cache-Control')
154
+ end).each { |k, v| req.headers[k] = v }
155
+ req.body = request.to_json
156
+ end
157
+
158
+ msg = "Received JSON-RPC response: #{response.status}"
159
+ msg += " #{response.body}" if response.respond_to?(:body)
160
+ @logger.debug(msg)
161
+ response
162
+ end
163
+
164
+ # Wait for an SSE result to arrive
165
+ # @param request [Hash] the original JSON-RPC request
166
+ # @return [Hash] the result data
167
+ # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
168
+ def wait_for_sse_result(request)
169
+ request_id = request[:id]
170
+ start_time = Time.now
171
+ timeout = @read_timeout || 10
172
+
173
+ ensure_sse_connection_active
174
+
175
+ wait_for_result_with_timeout(request_id, start_time, timeout)
176
+ end
177
+
178
+ # Ensure the SSE connection is active, reconnect if needed
179
+ def ensure_sse_connection_active
180
+ return if connection_active?
181
+
182
+ @logger.warn('SSE connection is not active, reconnecting before waiting for result')
183
+ begin
184
+ cleanup
185
+ connect
186
+ rescue MCPClient::Errors::ConnectionError => e
187
+ raise MCPClient::Errors::ConnectionError, "Failed to reconnect SSE for result: #{e.message}"
188
+ end
189
+ end
190
+
191
+ # Wait for a result with timeout
192
+ # @param request_id [Integer] the request ID to wait for
193
+ # @param start_time [Time] when the wait started
194
+ # @param timeout [Integer] the timeout in seconds
195
+ # @return [Hash] the result when available
196
+ # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
197
+ def wait_for_result_with_timeout(request_id, start_time, timeout)
198
+ loop do
199
+ result = check_for_result(request_id)
200
+ return result if result
201
+
202
+ unless connection_active?
203
+ raise MCPClient::Errors::ConnectionError,
204
+ 'SSE connection lost while waiting for result'
205
+ end
206
+
207
+ time_elapsed = Time.now - start_time
208
+ break if time_elapsed > timeout
209
+
210
+ sleep 0.1
211
+ end
212
+
213
+ raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
214
+ end
215
+
216
+ # Check if a result is available for the given request ID
217
+ # @param request_id [Integer] the request ID to check
218
+ # @return [Hash, nil] the result if available, nil otherwise
219
+ def check_for_result(request_id)
220
+ result = nil
221
+ @mutex.synchronize do
222
+ result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
223
+ end
224
+
225
+ if result
226
+ record_activity
227
+ return result
228
+ end
229
+
230
+ nil
231
+ end
232
+
233
+ # Parse a direct (non-SSE) JSON-RPC response
234
+ # @param response [Faraday::Response] the HTTP response
235
+ # @return [Hash] the parsed result
236
+ # @raise [MCPClient::Errors::TransportError] if parsing fails
237
+ # @raise [MCPClient::Errors::ServerError] if the response contains an error
238
+ def parse_direct_response(response)
239
+ data = JSON.parse(response.body)
240
+ process_jsonrpc_response(data)
241
+ rescue JSON::ParserError => e
242
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ class ServerSSE
5
+ # Extracted module for back-off, ping, and reconnection logic
6
+ module ReconnectMonitor
7
+ # Start an activity monitor thread to maintain the connection
8
+ # @return [void]
9
+ def start_activity_monitor
10
+ return if @activity_timer_thread&.alive?
11
+
12
+ @mutex.synchronize do
13
+ @last_activity_time = Time.now
14
+ @consecutive_ping_failures = 0
15
+ @max_ping_failures = DEFAULT_MAX_PING_FAILURES
16
+ @reconnect_attempts = 0
17
+ @max_reconnect_attempts = DEFAULT_MAX_RECONNECT_ATTEMPTS
18
+ end
19
+
20
+ @activity_timer_thread = Thread.new do
21
+ activity_monitor_loop
22
+ rescue StandardError => e
23
+ @logger.error("Activity monitor error: #{e.message}")
24
+ end
25
+ end
26
+
27
+ # Check if the connection is currently active
28
+ # @return [Boolean] true if connection is established and SSE is connected
29
+ def connection_active?
30
+ @mutex.synchronize { @connection_established && @sse_connected }
31
+ end
32
+
33
+ # Main loop for the activity monitor thread
34
+ # @return [void]
35
+ # @private
36
+ def activity_monitor_loop
37
+ loop do
38
+ sleep 1
39
+
40
+ unless connection_active?
41
+ @logger.debug('Activity monitor exiting: connection no longer active')
42
+ return
43
+ end
44
+
45
+ @mutex.synchronize do
46
+ @consecutive_ping_failures ||= 0
47
+ @reconnect_attempts ||= 0
48
+ @max_ping_failures ||= DEFAULT_MAX_PING_FAILURES
49
+ @max_reconnect_attempts ||= DEFAULT_MAX_RECONNECT_ATTEMPTS
50
+ end
51
+
52
+ return unless connection_active?
53
+
54
+ time_since_activity = Time.now - @last_activity_time
55
+
56
+ if @close_after && time_since_activity >= @close_after
57
+ @logger.info("Closing connection due to inactivity (#{time_since_activity.round(1)}s)")
58
+ cleanup
59
+ return
60
+ end
61
+
62
+ next unless @ping_interval && time_since_activity >= @ping_interval
63
+ return unless connection_active?
64
+
65
+ if @consecutive_ping_failures >= @max_ping_failures
66
+ attempt_reconnection
67
+ else
68
+ attempt_ping
69
+ end
70
+ end
71
+ end
72
+
73
+ # Attempt to reconnect with exponential backoff
74
+ # @return [void]
75
+ # @private
76
+ def attempt_reconnection
77
+ if @reconnect_attempts < @max_reconnect_attempts
78
+ begin
79
+ base_delay = BASE_RECONNECT_DELAY * (2**@reconnect_attempts)
80
+ jitter = rand * JITTER_FACTOR * base_delay
81
+ backoff_delay = [base_delay + jitter, MAX_RECONNECT_DELAY].min
82
+
83
+ reconnect_msg = "Attempting to reconnect (attempt #{@reconnect_attempts + 1}/#{@max_reconnect_attempts}) "
84
+ reconnect_msg += "after #{@consecutive_ping_failures} consecutive ping failures. "
85
+ reconnect_msg += "Waiting #{backoff_delay.round(2)}s before reconnect..."
86
+ @logger.warn(reconnect_msg)
87
+ sleep(backoff_delay)
88
+
89
+ cleanup
90
+
91
+ connect
92
+ @logger.info('Successfully reconnected after ping failures')
93
+
94
+ @mutex.synchronize do
95
+ @consecutive_ping_failures = 0
96
+ @reconnect_attempts += 1
97
+ @last_activity_time = Time.now
98
+ end
99
+ rescue StandardError => e
100
+ @logger.error("Failed to reconnect after ping failures: #{e.message}")
101
+ @mutex.synchronize { @reconnect_attempts += 1 }
102
+ end
103
+ else
104
+ @logger.error("Exceeded maximum reconnection attempts (#{@max_reconnect_attempts}). Closing connection.")
105
+ cleanup
106
+ end
107
+ end
108
+
109
+ # Attempt to ping the server to check if connection is still alive
110
+ # @return [void]
111
+ # @private
112
+ def attempt_ping
113
+ unless connection_active?
114
+ @logger.debug('Skipping ping - connection not active')
115
+ return
116
+ end
117
+
118
+ time_since = Time.now - @last_activity_time
119
+ @logger.debug("Sending ping after #{time_since.round(1)}s of inactivity")
120
+
121
+ begin
122
+ ping
123
+ @mutex.synchronize do
124
+ @last_activity_time = Time.now
125
+ @consecutive_ping_failures = 0
126
+ end
127
+ rescue StandardError => e
128
+ unless connection_active?
129
+ @logger.debug("Ignoring ping failure - connection already closed: #{e.message}")
130
+ return
131
+ end
132
+ handle_ping_failure(e)
133
+ end
134
+ end
135
+
136
+ # Handle ping failures by incrementing a counter and logging
137
+ # @param error [StandardError] the error that caused the ping failure
138
+ # @return [void]
139
+ # @private
140
+ def handle_ping_failure(error)
141
+ @mutex.synchronize { @consecutive_ping_failures += 1 }
142
+ consecutive_failures = @consecutive_ping_failures
143
+
144
+ if consecutive_failures == 1
145
+ @logger.error("Error sending ping: #{error.message}")
146
+ else
147
+ error_msg = error.message.split("\n").first
148
+ @logger.warn("Ping failed (#{consecutive_failures}/#{@max_ping_failures}): #{error_msg}")
149
+ end
150
+ end
151
+
152
+ # Record activity to prevent unnecessary pings
153
+ # @return [void]
154
+ def record_activity
155
+ @mutex.synchronize { @last_activity_time = Time.now }
156
+ end
157
+
158
+ # Wait for the connection to be established
159
+ # @param timeout [Numeric] timeout in seconds
160
+ # @return [void]
161
+ # @raise [MCPClient::Errors::ConnectionError] if connection times out or fails
162
+ def wait_for_connection(timeout:)
163
+ @mutex.synchronize do
164
+ deadline = Time.now + timeout
165
+
166
+ until @connection_established
167
+ remaining = [1, deadline - Time.now].min
168
+ break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
169
+ end
170
+
171
+ raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
172
+
173
+ unless @connection_established
174
+ cleanup
175
+ error_msg = "Failed to connect to MCP server at #{@base_url}"
176
+ error_msg += ': Timed out waiting for SSE connection to be established'
177
+ raise MCPClient::Errors::ConnectionError, error_msg
178
+ end
179
+ end
180
+ end
181
+
182
+ # Setup the SSE connection with Faraday
183
+ # @param uri [URI] the URI to connect to
184
+ # @return [Faraday::Connection] the configured connection
185
+ # @private
186
+ def setup_sse_connection(uri)
187
+ sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
188
+
189
+ @sse_conn ||= Faraday.new(url: sse_base) do |f|
190
+ f.options.open_timeout = 10
191
+ f.options.timeout = nil
192
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
193
+ f.adapter Faraday.default_adapter
194
+ end
195
+
196
+ @sse_conn.builder.use Faraday::Response::RaiseError
197
+ @sse_conn
198
+ end
199
+
200
+ # Handle authentication errors from SSE
201
+ # @param error [StandardError] the authentication error
202
+ # @return [void]
203
+ # @private
204
+ def handle_sse_auth_error(error)
205
+ error_message = "Authorization failed: HTTP #{error.response[:status]}"
206
+ @logger.error(error_message)
207
+
208
+ @mutex.synchronize do
209
+ @auth_error = error_message
210
+ @connection_established = false
211
+ @connection_cv.broadcast
212
+ end
213
+ end
214
+
215
+ # Reset the connection state
216
+ # @return [void]
217
+ # @private
218
+ def reset_connection_state
219
+ @mutex.synchronize do
220
+ @connection_established = false
221
+ @connection_cv.broadcast
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module MCPClient
6
+ class ServerSSE
7
+ # === Wire-level SSE parsing & dispatch ===
8
+ module SseParser
9
+ # Parse and handle a raw SSE event payload.
10
+ # @param event_data [String] the raw event chunk
11
+ def parse_and_handle_sse_event(event_data)
12
+ event = parse_sse_event(event_data)
13
+ return if event.nil?
14
+
15
+ case event[:event]
16
+ when 'endpoint'
17
+ handle_endpoint_event(event[:data])
18
+ when 'ping'
19
+ # no-op
20
+ when 'message'
21
+ handle_message_event(event)
22
+ end
23
+ end
24
+
25
+ # Handle a "message" SSE event (payload is JSON-RPC over SSE)
26
+ # @param event [Hash] the parsed SSE event (with :data, :id, etc)
27
+ def handle_message_event(event)
28
+ return if event[:data].empty?
29
+
30
+ begin
31
+ data = JSON.parse(event[:data])
32
+
33
+ return if process_error_in_message(data)
34
+ return if process_notification(data)
35
+
36
+ process_response(data)
37
+ rescue MCPClient::Errors::ConnectionError
38
+ raise
39
+ rescue JSON::ParserError => e
40
+ @logger.warn("Failed to parse JSON from event data: #{e.message}")
41
+ rescue StandardError => e
42
+ @logger.error("Error processing SSE event: #{e.message}")
43
+ end
44
+ end
45
+
46
+ # Process a JSON-RPC error() in the SSE stream.
47
+ # @param data [Hash] the parsed JSON payload
48
+ # @return [Boolean] true if we saw & handled an error
49
+ def process_error_in_message(data)
50
+ return unless data['error']
51
+
52
+ error_message = data['error']['message'] || 'Unknown server error'
53
+ error_code = data['error']['code']
54
+
55
+ handle_sse_auth_error_message(error_message) if authorization_error?(error_message, error_code)
56
+
57
+ @logger.error("Server error: #{error_message}")
58
+ true
59
+ end
60
+
61
+ # Process a JSON-RPC notification (no id => notification)
62
+ # @param data [Hash] the parsed JSON payload
63
+ # @return [Boolean] true if we saw & handled a notification
64
+ def process_notification(data)
65
+ return false unless data['method'] && !data.key?('id')
66
+
67
+ @notification_callback&.call(data['method'], data['params'])
68
+ true
69
+ end
70
+
71
+ # Process a JSON-RPC response (id => response)
72
+ # @param data [Hash] the parsed JSON payload
73
+ # @return [Boolean] true if we saw & handled a response
74
+ def process_response(data)
75
+ return false unless data['id']
76
+
77
+ @mutex.synchronize do
78
+ @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
79
+
80
+ @sse_results[data['id']] =
81
+ if data['error']
82
+ { 'isError' => true,
83
+ 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }] }
84
+ else
85
+ data['result']
86
+ end
87
+ end
88
+
89
+ true
90
+ end
91
+
92
+ # Parse a raw SSE chunk into its :event, :data, :id fields
93
+ # @param event_data [String] the raw SSE block
94
+ # @return [Hash,nil] parsed fields or nil if it was pure comment/blank
95
+ def parse_sse_event(event_data)
96
+ event = { event: 'message', data: '', id: nil }
97
+ data_lines = []
98
+ has_content = false
99
+
100
+ event_data.each_line do |line|
101
+ line = line.chomp
102
+ next if line.empty? # blank line
103
+ next if line.start_with?(':') # SSE comment
104
+
105
+ has_content = true
106
+ if line.start_with?('event:')
107
+ event[:event] = line[6..].strip
108
+ elsif line.start_with?('data:')
109
+ data_lines << line[5..].strip
110
+ elsif line.start_with?('id:')
111
+ event[:id] = line[3..].strip
112
+ end
113
+ end
114
+
115
+ event[:data] = data_lines.join("\n")
116
+ has_content ? event : nil
117
+ end
118
+
119
+ # Handle the special "endpoint" control frame (for SSE handshake)
120
+ # @param data [String] the raw endpoint payload
121
+ def handle_endpoint_event(data)
122
+ @mutex.synchronize do
123
+ @rpc_endpoint = data
124
+ @sse_connected = true
125
+ @connection_established = true
126
+ @connection_cv.broadcast
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end