ruby-mcp-client 0.6.2 → 0.7.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 +316 -10
- data/lib/mcp_client/auth/oauth_provider.rb +514 -0
- data/lib/mcp_client/auth.rb +315 -0
- data/lib/mcp_client/client.rb +1 -1
- data/lib/mcp_client/config_parser.rb +73 -1
- data/lib/mcp_client/http_transport_base.rb +283 -0
- data/lib/mcp_client/json_rpc_common.rb +8 -10
- data/lib/mcp_client/oauth_client.rb +127 -0
- data/lib/mcp_client/server_factory.rb +42 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +27 -0
- data/lib/mcp_client/server_http.rb +331 -0
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +5 -5
- data/lib/mcp_client/server_sse.rb +16 -8
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1 -1
- data/lib/mcp_client/server_stdio.rb +1 -1
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +76 -0
- data/lib/mcp_client/server_streamable_http.rb +332 -0
- data/lib/mcp_client/tool.rb +4 -3
- data/lib/mcp_client/version.rb +4 -1
- data/lib/mcp_client.rb +61 -2
- metadata +10 -2
@@ -0,0 +1,331 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
require 'json'
|
5
|
+
require 'monitor'
|
6
|
+
require 'logger'
|
7
|
+
require 'faraday'
|
8
|
+
require 'faraday/retry'
|
9
|
+
|
10
|
+
module MCPClient
|
11
|
+
# Implementation of MCP server that communicates via HTTP requests/responses
|
12
|
+
# Useful for communicating with MCP servers that support HTTP-based transport
|
13
|
+
# without Server-Sent Events streaming
|
14
|
+
class ServerHTTP < ServerBase
|
15
|
+
require_relative 'server_http/json_rpc_transport'
|
16
|
+
|
17
|
+
include JsonRpcTransport
|
18
|
+
|
19
|
+
# Default values for connection settings
|
20
|
+
DEFAULT_READ_TIMEOUT = 30
|
21
|
+
DEFAULT_MAX_RETRIES = 3
|
22
|
+
|
23
|
+
# @!attribute [r] base_url
|
24
|
+
# @return [String] The base URL of the MCP server
|
25
|
+
# @!attribute [r] endpoint
|
26
|
+
# @return [String] The JSON-RPC endpoint path
|
27
|
+
# @!attribute [r] tools
|
28
|
+
# @return [Array<MCPClient::Tool>, nil] List of available tools (nil if not fetched yet)
|
29
|
+
attr_reader :base_url, :endpoint, :tools
|
30
|
+
|
31
|
+
# Server information from initialize response
|
32
|
+
# @return [Hash, nil] Server information
|
33
|
+
attr_reader :server_info
|
34
|
+
|
35
|
+
# Server capabilities from initialize response
|
36
|
+
# @return [Hash, nil] Server capabilities
|
37
|
+
attr_reader :capabilities
|
38
|
+
|
39
|
+
# @param base_url [String] The base URL of the MCP server
|
40
|
+
# @param options [Hash] Server configuration options
|
41
|
+
# @option options [String] :endpoint JSON-RPC endpoint path (default: '/rpc')
|
42
|
+
# @option options [Hash] :headers Additional headers to include in requests
|
43
|
+
# @option options [Integer] :read_timeout Read timeout in seconds (default: 30)
|
44
|
+
# @option options [Integer] :retries Retry attempts on transient errors (default: 3)
|
45
|
+
# @option options [Numeric] :retry_backoff Base delay for exponential backoff (default: 1)
|
46
|
+
# @option options [String, nil] :name Optional name for this server
|
47
|
+
# @option options [Logger, nil] :logger Optional logger
|
48
|
+
# @option options [MCPClient::Auth::OAuthProvider, nil] :oauth_provider Optional OAuth provider
|
49
|
+
def initialize(base_url:, **options)
|
50
|
+
opts = default_options.merge(options)
|
51
|
+
super(name: opts[:name])
|
52
|
+
@logger = opts[:logger] || Logger.new($stdout, level: Logger::WARN)
|
53
|
+
@logger.progname = self.class.name
|
54
|
+
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
55
|
+
|
56
|
+
@max_retries = opts[:retries]
|
57
|
+
@retry_backoff = opts[:retry_backoff]
|
58
|
+
|
59
|
+
# Validate and normalize base_url
|
60
|
+
raise ArgumentError, "Invalid or insecure server URL: #{base_url}" unless valid_server_url?(base_url)
|
61
|
+
|
62
|
+
# Normalize base_url and handle cases where full endpoint is provided in base_url
|
63
|
+
uri = URI.parse(base_url.chomp('/'))
|
64
|
+
|
65
|
+
# Helper to build base URL without default ports
|
66
|
+
build_base_url = lambda do |parsed_uri|
|
67
|
+
port_part = if parsed_uri.port &&
|
68
|
+
!((parsed_uri.scheme == 'http' && parsed_uri.port == 80) ||
|
69
|
+
(parsed_uri.scheme == 'https' && parsed_uri.port == 443))
|
70
|
+
":#{parsed_uri.port}"
|
71
|
+
else
|
72
|
+
''
|
73
|
+
end
|
74
|
+
"#{parsed_uri.scheme}://#{parsed_uri.host}#{port_part}"
|
75
|
+
end
|
76
|
+
|
77
|
+
@base_url = build_base_url.call(uri)
|
78
|
+
@endpoint = if uri.path && !uri.path.empty? && uri.path != '/' && opts[:endpoint] == '/rpc'
|
79
|
+
# If base_url contains a path and we're using default endpoint,
|
80
|
+
# treat the path as the endpoint and use the base URL without path
|
81
|
+
uri.path
|
82
|
+
else
|
83
|
+
# Standard case: base_url is just scheme://host:port, endpoint is separate
|
84
|
+
opts[:endpoint]
|
85
|
+
end
|
86
|
+
|
87
|
+
# Set up headers for HTTP requests
|
88
|
+
@headers = opts[:headers].merge({
|
89
|
+
'Content-Type' => 'application/json',
|
90
|
+
'Accept' => 'application/json',
|
91
|
+
'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}"
|
92
|
+
})
|
93
|
+
|
94
|
+
@read_timeout = opts[:read_timeout]
|
95
|
+
@tools = nil
|
96
|
+
@tools_data = nil
|
97
|
+
@request_id = 0
|
98
|
+
@mutex = Monitor.new
|
99
|
+
@connection_established = false
|
100
|
+
@initialized = false
|
101
|
+
@http_conn = nil
|
102
|
+
@session_id = nil
|
103
|
+
@oauth_provider = opts[:oauth_provider]
|
104
|
+
end
|
105
|
+
|
106
|
+
# Connect to the MCP server over HTTP
|
107
|
+
# @return [Boolean] true if connection was successful
|
108
|
+
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
109
|
+
def connect
|
110
|
+
return true if @mutex.synchronize { @connection_established }
|
111
|
+
|
112
|
+
begin
|
113
|
+
@mutex.synchronize do
|
114
|
+
@connection_established = false
|
115
|
+
@initialized = false
|
116
|
+
end
|
117
|
+
|
118
|
+
# Test connectivity with a simple HTTP request
|
119
|
+
test_connection
|
120
|
+
|
121
|
+
# Perform MCP initialization handshake
|
122
|
+
perform_initialize
|
123
|
+
|
124
|
+
@mutex.synchronize do
|
125
|
+
@connection_established = true
|
126
|
+
@initialized = true
|
127
|
+
end
|
128
|
+
|
129
|
+
true
|
130
|
+
rescue MCPClient::Errors::ConnectionError => e
|
131
|
+
cleanup
|
132
|
+
raise e
|
133
|
+
rescue StandardError => e
|
134
|
+
cleanup
|
135
|
+
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# List all tools available from the MCP server
|
140
|
+
# @return [Array<MCPClient::Tool>] list of available tools
|
141
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
142
|
+
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
143
|
+
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
144
|
+
def list_tools
|
145
|
+
@mutex.synchronize do
|
146
|
+
return @tools if @tools
|
147
|
+
end
|
148
|
+
|
149
|
+
begin
|
150
|
+
ensure_connected
|
151
|
+
|
152
|
+
tools_data = request_tools_list
|
153
|
+
@mutex.synchronize do
|
154
|
+
@tools = tools_data.map do |tool_data|
|
155
|
+
MCPClient::Tool.from_json(tool_data, server: self)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
@mutex.synchronize { @tools }
|
160
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
161
|
+
# Re-raise these errors directly
|
162
|
+
raise
|
163
|
+
rescue StandardError => e
|
164
|
+
raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Call a tool with the given parameters
|
169
|
+
# @param tool_name [String] the name of the tool to call
|
170
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
171
|
+
# @return [Object] the result of the tool invocation
|
172
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
173
|
+
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
174
|
+
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
175
|
+
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
176
|
+
def call_tool(tool_name, parameters)
|
177
|
+
rpc_request('tools/call', {
|
178
|
+
name: tool_name,
|
179
|
+
arguments: parameters
|
180
|
+
})
|
181
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
182
|
+
# Re-raise connection/transport errors directly to match test expectations
|
183
|
+
raise
|
184
|
+
rescue StandardError => e
|
185
|
+
# For all other errors, wrap in ToolCallError
|
186
|
+
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
187
|
+
end
|
188
|
+
|
189
|
+
# Override apply_request_headers to add session headers for MCP protocol
|
190
|
+
def apply_request_headers(req, request)
|
191
|
+
super
|
192
|
+
|
193
|
+
# Add session header if we have one (for non-initialize requests)
|
194
|
+
return unless @session_id && request['method'] != 'initialize'
|
195
|
+
|
196
|
+
req.headers['Mcp-Session-Id'] = @session_id
|
197
|
+
@logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
|
198
|
+
end
|
199
|
+
|
200
|
+
# Override handle_successful_response to capture session ID
|
201
|
+
def handle_successful_response(response, request)
|
202
|
+
super
|
203
|
+
|
204
|
+
# Capture session ID from initialize response with validation
|
205
|
+
return unless request['method'] == 'initialize' && response.success?
|
206
|
+
|
207
|
+
session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
|
208
|
+
if session_id
|
209
|
+
if valid_session_id?(session_id)
|
210
|
+
@session_id = session_id
|
211
|
+
@logger.debug("Captured session ID: #{@session_id}")
|
212
|
+
else
|
213
|
+
@logger.warn("Invalid session ID format received: #{session_id.inspect}")
|
214
|
+
end
|
215
|
+
else
|
216
|
+
@logger.warn('No session ID found in initialize response headers')
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# Stream tool call (default implementation returns single-value stream)
|
221
|
+
# @param tool_name [String] the name of the tool to call
|
222
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
223
|
+
# @return [Enumerator] stream of results
|
224
|
+
def call_tool_streaming(tool_name, parameters)
|
225
|
+
Enumerator.new do |yielder|
|
226
|
+
yielder << call_tool(tool_name, parameters)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
# Terminate the current session (if any)
|
231
|
+
# @return [Boolean] true if termination was successful or no session exists
|
232
|
+
def terminate_session
|
233
|
+
@mutex.synchronize do
|
234
|
+
return true unless @session_id
|
235
|
+
|
236
|
+
super
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
# Clean up the server connection
|
241
|
+
# Properly closes HTTP connections and clears cached state
|
242
|
+
def cleanup
|
243
|
+
@mutex.synchronize do
|
244
|
+
# Attempt to terminate session before cleanup
|
245
|
+
terminate_session if @session_id
|
246
|
+
|
247
|
+
@connection_established = false
|
248
|
+
@initialized = false
|
249
|
+
|
250
|
+
@logger.debug('Cleaning up HTTP connection')
|
251
|
+
|
252
|
+
# Close HTTP connection if it exists
|
253
|
+
@http_conn = nil
|
254
|
+
@session_id = nil
|
255
|
+
|
256
|
+
@tools = nil
|
257
|
+
@tools_data = nil
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
private
|
262
|
+
|
263
|
+
# Default options for server initialization
|
264
|
+
# @return [Hash] Default options
|
265
|
+
def default_options
|
266
|
+
{
|
267
|
+
endpoint: '/rpc',
|
268
|
+
headers: {},
|
269
|
+
read_timeout: DEFAULT_READ_TIMEOUT,
|
270
|
+
retries: DEFAULT_MAX_RETRIES,
|
271
|
+
retry_backoff: 1,
|
272
|
+
name: nil,
|
273
|
+
logger: nil,
|
274
|
+
oauth_provider: nil
|
275
|
+
}
|
276
|
+
end
|
277
|
+
|
278
|
+
# Test basic connectivity to the HTTP endpoint
|
279
|
+
# @return [void]
|
280
|
+
# @raise [MCPClient::Errors::ConnectionError] if connection test fails
|
281
|
+
def test_connection
|
282
|
+
create_http_connection
|
283
|
+
|
284
|
+
# Simple connectivity test - we'll use the actual initialize call
|
285
|
+
# since there's no standard HTTP health check endpoint
|
286
|
+
rescue Faraday::ConnectionFailed => e
|
287
|
+
raise MCPClient::Errors::ConnectionError, "Cannot connect to server at #{@base_url}: #{e.message}"
|
288
|
+
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
289
|
+
error_status = e.response ? e.response[:status] : 'unknown'
|
290
|
+
raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
|
291
|
+
rescue Faraday::Error => e
|
292
|
+
raise MCPClient::Errors::ConnectionError, "HTTP connection error: #{e.message}"
|
293
|
+
end
|
294
|
+
|
295
|
+
# Ensure connection is established
|
296
|
+
# @return [void]
|
297
|
+
# @raise [MCPClient::Errors::ConnectionError] if connection is not established
|
298
|
+
def ensure_connected
|
299
|
+
return if @mutex.synchronize { @connection_established && @initialized }
|
300
|
+
|
301
|
+
@logger.debug('Connection not active, attempting to reconnect before request')
|
302
|
+
cleanup
|
303
|
+
connect
|
304
|
+
end
|
305
|
+
|
306
|
+
# Request the tools list using JSON-RPC
|
307
|
+
# @return [Array<Hash>] the tools data
|
308
|
+
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
309
|
+
def request_tools_list
|
310
|
+
@mutex.synchronize do
|
311
|
+
return @tools_data if @tools_data
|
312
|
+
end
|
313
|
+
|
314
|
+
result = rpc_request('tools/list')
|
315
|
+
|
316
|
+
if result && result['tools']
|
317
|
+
@mutex.synchronize do
|
318
|
+
@tools_data = result['tools']
|
319
|
+
end
|
320
|
+
return @mutex.synchronize { @tools_data.dup }
|
321
|
+
elsif result
|
322
|
+
@mutex.synchronize do
|
323
|
+
@tools_data = result
|
324
|
+
end
|
325
|
+
return @mutex.synchronize { @tools_data.dup }
|
326
|
+
end
|
327
|
+
|
328
|
+
raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require_relative '../json_rpc_common'
|
4
4
|
|
5
5
|
module MCPClient
|
6
6
|
class ServerSSE
|
@@ -68,8 +68,8 @@ module MCPClient
|
|
68
68
|
result = send_jsonrpc_request(json_rpc_request)
|
69
69
|
return unless result.is_a?(Hash)
|
70
70
|
|
71
|
-
@server_info = result['serverInfo']
|
72
|
-
@capabilities = result['capabilities']
|
71
|
+
@server_info = result['serverInfo']
|
72
|
+
@capabilities = result['capabilities']
|
73
73
|
end
|
74
74
|
|
75
75
|
# Send a JSON-RPC request to the server and wait for result
|
@@ -97,7 +97,7 @@ module MCPClient
|
|
97
97
|
rescue Errno::ECONNREFUSED => e
|
98
98
|
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
99
99
|
rescue StandardError => e
|
100
|
-
method_name = request[
|
100
|
+
method_name = request['method']
|
101
101
|
raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
|
102
102
|
end
|
103
103
|
end
|
@@ -166,7 +166,7 @@ module MCPClient
|
|
166
166
|
# @return [Hash] the result data
|
167
167
|
# @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
|
168
168
|
def wait_for_sse_result(request)
|
169
|
-
request_id = request[
|
169
|
+
request_id = request['id']
|
170
170
|
start_time = Time.now
|
171
171
|
timeout = @read_timeout || 10
|
172
172
|
|
@@ -11,12 +11,12 @@ module MCPClient
|
|
11
11
|
# Implementation of MCP server that communicates via Server-Sent Events (SSE)
|
12
12
|
# Useful for communicating with remote MCP servers over HTTP
|
13
13
|
class ServerSSE < ServerBase
|
14
|
-
|
15
|
-
|
14
|
+
require_relative 'server_sse/sse_parser'
|
15
|
+
require_relative 'server_sse/json_rpc_transport'
|
16
16
|
|
17
17
|
include SseParser
|
18
18
|
include JsonRpcTransport
|
19
|
-
|
19
|
+
require_relative 'server_sse/reconnect_monitor'
|
20
20
|
|
21
21
|
include ReconnectMonitor
|
22
22
|
# Ratio of close_after timeout to ping interval
|
@@ -35,11 +35,15 @@ module MCPClient
|
|
35
35
|
# @return [String] The base URL of the MCP server
|
36
36
|
# @!attribute [r] tools
|
37
37
|
# @return [Array<MCPClient::Tool>, nil] List of available tools (nil if not fetched yet)
|
38
|
-
|
39
|
-
|
40
|
-
#
|
41
|
-
#
|
42
|
-
attr_reader :
|
38
|
+
attr_reader :base_url, :tools
|
39
|
+
|
40
|
+
# Server information from initialize response
|
41
|
+
# @return [Hash, nil] Server information
|
42
|
+
attr_reader :server_info
|
43
|
+
|
44
|
+
# Server capabilities from initialize response
|
45
|
+
# @return [Hash, nil] Server capabilities
|
46
|
+
attr_reader :capabilities
|
43
47
|
|
44
48
|
# @param base_url [String] The base URL of the MCP server
|
45
49
|
# @param headers [Hash] Additional headers to include in requests
|
@@ -138,6 +142,10 @@ module MCPClient
|
|
138
142
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
139
143
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
140
144
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
145
|
+
# Call a tool with the given parameters
|
146
|
+
# @param tool_name [String] the name of the tool to call
|
147
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
148
|
+
# @return [Object] the result of the tool invocation (with string keys for backward compatibility)
|
141
149
|
def call_tool(tool_name, parameters)
|
142
150
|
rpc_request('tools/call', {
|
143
151
|
name: tool_name,
|
@@ -8,7 +8,7 @@ require 'logger'
|
|
8
8
|
module MCPClient
|
9
9
|
# JSON-RPC implementation of MCP server over stdio.
|
10
10
|
class ServerStdio < ServerBase
|
11
|
-
|
11
|
+
require_relative 'server_stdio/json_rpc_transport'
|
12
12
|
|
13
13
|
include JsonRpcTransport
|
14
14
|
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../http_transport_base'
|
4
|
+
|
5
|
+
module MCPClient
|
6
|
+
class ServerStreamableHTTP
|
7
|
+
# JSON-RPC request/notification plumbing for Streamable HTTP transport
|
8
|
+
# This transport uses HTTP POST requests but expects Server-Sent Event formatted responses
|
9
|
+
module JsonRpcTransport
|
10
|
+
include HttpTransportBase
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
# Log HTTP response for Streamable HTTP
|
15
|
+
# @param response [Faraday::Response] the HTTP response
|
16
|
+
def log_response(response)
|
17
|
+
@logger.debug("Received Streamable HTTP response: #{response.status} #{response.body}")
|
18
|
+
end
|
19
|
+
|
20
|
+
# Parse a Streamable HTTP JSON-RPC response (JSON or SSE format)
|
21
|
+
# @param response [Faraday::Response] the HTTP response
|
22
|
+
# @return [Hash] the parsed result
|
23
|
+
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
24
|
+
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
25
|
+
def parse_response(response)
|
26
|
+
body = response.body.strip
|
27
|
+
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
|
28
|
+
|
29
|
+
# Determine response format based on Content-Type header per MCP 2025 spec
|
30
|
+
data = if content_type.include?('text/event-stream')
|
31
|
+
# Parse SSE-formatted response for streaming
|
32
|
+
parse_sse_response(body)
|
33
|
+
else
|
34
|
+
# Parse regular JSON response (default for Streamable HTTP)
|
35
|
+
JSON.parse(body)
|
36
|
+
end
|
37
|
+
|
38
|
+
process_jsonrpc_response(data)
|
39
|
+
rescue JSON::ParserError => e
|
40
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Parse Server-Sent Event formatted response with event ID tracking
|
44
|
+
# @param sse_body [String] the SSE formatted response body
|
45
|
+
# @return [Hash] the parsed JSON data
|
46
|
+
# @raise [MCPClient::Errors::TransportError] if no data found in SSE response
|
47
|
+
def parse_sse_response(sse_body)
|
48
|
+
# Extract JSON data and event ID from SSE format
|
49
|
+
# SSE format: event: message\nid: 123\ndata: {...}\n\n
|
50
|
+
data_lines = []
|
51
|
+
event_id = nil
|
52
|
+
|
53
|
+
sse_body.lines.each do |line|
|
54
|
+
line = line.strip
|
55
|
+
if line.start_with?('data:')
|
56
|
+
data_lines << line.sub(/^data:\s*/, '').strip
|
57
|
+
elsif line.start_with?('id:')
|
58
|
+
event_id = line.sub(/^id:\s*/, '').strip
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
raise MCPClient::Errors::TransportError, 'No data found in SSE response' if data_lines.empty?
|
63
|
+
|
64
|
+
# Track the last event ID for resumability
|
65
|
+
if event_id && !event_id.empty?
|
66
|
+
@last_event_id = event_id
|
67
|
+
@logger.debug("Tracking event ID for resumability: #{event_id}")
|
68
|
+
end
|
69
|
+
|
70
|
+
# Join multiline data fields according to SSE spec
|
71
|
+
json_data = data_lines.join("\n")
|
72
|
+
JSON.parse(json_data)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|