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.
- checksums.yaml +4 -4
- data/README.md +41 -11
- data/lib/mcp_client/client.rb +125 -26
- data/lib/mcp_client/config_parser.rb +5 -1
- data/lib/mcp_client/errors.rb +4 -0
- data/lib/mcp_client/json_rpc_common.rb +84 -0
- data/lib/mcp_client/server_base.rb +20 -0
- data/lib/mcp_client/server_factory.rb +54 -17
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +246 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +226 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +131 -0
- data/lib/mcp_client/server_sse.rb +174 -489
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +122 -0
- data/lib/mcp_client/server_stdio.rb +36 -127
- data/lib/mcp_client/tool.rb +22 -4
- data/lib/mcp_client/version.rb +4 -1
- data/lib/mcp_client.rb +27 -10
- metadata +7 -2
@@ -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
|