ruby-mcp-client 1.1.0 → 2.0.0

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.
@@ -35,10 +35,13 @@ module MCPClient
35
35
  raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
36
36
  end
37
37
 
38
- # Store server info and capabilities
38
+ # Store negotiated protocol version, server info and capabilities.
39
+ # Disconnects if the server negotiated a version we cannot speak.
39
40
  result = res['result'] || {}
41
+ @protocol_version = validate_protocol_version!(result)
40
42
  @server_info = result['serverInfo']
41
43
  @capabilities = result['capabilities']
44
+ @instructions = result['instructions']
42
45
 
43
46
  # Send initialized notification
44
47
  notif = build_jsonrpc_notification('notifications/initialized', {})
@@ -77,8 +80,8 @@ module MCPClient
77
80
  # @param id [Integer] the request ID
78
81
  # @return [Hash] the JSON-RPC response message
79
82
  # @raise [MCPClient::Errors::TransportError] on timeout
80
- def wait_response(id)
81
- deadline = Time.now + @read_timeout
83
+ def wait_response(id, timeout: nil)
84
+ deadline = Time.now + (timeout || @read_timeout)
82
85
  @mutex.synchronize do
83
86
  until @pending.key?(id)
84
87
  remaining = deadline - Time.now
@@ -90,7 +93,7 @@ module MCPClient
90
93
  # timeout so neither @pending nor @awaiting accumulates entries.
91
94
  msg = @pending.delete(id)
92
95
  @awaiting.delete(id)
93
- raise MCPClient::Errors::TransportError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
96
+ raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
94
97
 
95
98
  msg
96
99
  end
@@ -113,17 +116,37 @@ module MCPClient
113
116
  # @raise [MCPClient::Errors::ServerError] if server returns an error
114
117
  # @raise [MCPClient::Errors::TransportError] on transport errors
115
118
  # @raise [MCPClient::Errors::ToolCallError] on tool call errors
116
- def rpc_request(method, params = {})
119
+ def rpc_request(method, params = {}, timeout: nil)
117
120
  ensure_initialized
118
121
  with_retry do
119
122
  req_id = next_id
120
123
  req = build_jsonrpc_request(method, params, req_id)
121
124
  send_request(req)
122
- res = wait_response(req_id)
125
+ begin
126
+ res = wait_response(req_id, timeout: timeout)
127
+ rescue MCPClient::Errors::RequestTimeoutError
128
+ # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
129
+ # notification for the abandoned request and stop waiting.
130
+ send_cancellation_notification(req_id) if cancellable_request?(method, params)
131
+ raise
132
+ end
123
133
  process_jsonrpc_response(res)
124
134
  end
125
135
  end
126
136
 
137
+ # Best-effort notifications/cancelled for a request the client stopped
138
+ # waiting on. Failures are swallowed: the transport may be the reason
139
+ # the request timed out in the first place.
140
+ # @param request_id [Integer] id of the abandoned request
141
+ # @return [void]
142
+ def send_cancellation_notification(request_id)
143
+ notif = build_jsonrpc_notification('notifications/cancelled',
144
+ { 'requestId' => request_id, 'reason' => 'Request timed out' })
145
+ @stdin.puts(notif.to_json)
146
+ rescue StandardError => e
147
+ @logger.debug("Failed to send cancellation notification: #{e.message}")
148
+ end
149
+
127
150
  # Send a JSON-RPC notification (no response expected)
128
151
  # @param method [String] JSON-RPC method
129
152
  # @param params [Hash] parameters for the notification
@@ -21,6 +21,12 @@ module MCPClient
21
21
  # Timeout in seconds for responses
22
22
  READ_TIMEOUT = 15
23
23
 
24
+ # Grace period in seconds allowed at each stage of the shutdown sequence
25
+ # (after closing stdin, then after SIGTERM) before escalating further, per
26
+ # MCP 2025-11-25 basic/lifecycle.mdx (Shutdown / stdio): close stdin, wait
27
+ # for the server to exit, send SIGTERM, then SIGKILL if it still runs.
28
+ SHUTDOWN_GRACE_PERIOD = 2
29
+
24
30
  # Chunk size (bytes) used when draining the subprocess stderr pipe
25
31
  STDERR_READ_CHUNK_SIZE = 8192
26
32
 
@@ -87,11 +93,25 @@ module MCPClient
87
93
  else
88
94
  @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command)
89
95
  end
96
+ pin_pipe_encodings
90
97
  true
91
98
  rescue StandardError => e
92
99
  raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
93
100
  end
94
101
 
102
+ # Pin the subprocess pipe encodings to UTF-8 instead of inheriting the
103
+ # process locale (Encoding.default_external). JSON-RPC messages MUST be
104
+ # UTF-8 encoded (MCP 2025-11-25 basic/transports.mdx); under a non-UTF-8
105
+ # locale (e.g. LANG=C) a valid UTF-8 message would otherwise fail to
106
+ # decode and kill the reader thread. The server MAY also write UTF-8 to
107
+ # stderr, so that pipe is pinned as well.
108
+ # @return [void]
109
+ def pin_pipe_encodings
110
+ [@stdin, @stdout, @stderr].each do |io|
111
+ io&.set_encoding(Encoding::UTF_8)
112
+ end
113
+ end
114
+
95
115
  # Spawn a reader thread to collect JSON-RPC responses
96
116
  # @return [Thread] the reader thread
97
117
  def start_reader
@@ -142,6 +162,13 @@ module MCPClient
142
162
  msg = JSON.parse(line)
143
163
  @logger.debug("Received line: #{line.chomp}")
144
164
 
165
+ # A JSON-parseable line that is not an object cannot be a JSON-RPC
166
+ # message; skip it rather than raising inside the reader thread
167
+ unless msg.is_a?(Hash)
168
+ @logger.debug("Skipping non-object JSON-RPC line: #{line.chomp}")
169
+ return
170
+ end
171
+
145
172
  # Dispatch JSON-RPC requests from server (has id AND method) - MCP 2025-06-18
146
173
  if msg['method'] && msg.key?('id')
147
174
  handle_server_request(msg)
@@ -169,8 +196,9 @@ module MCPClient
169
196
  @logger.debug("Discarding response for unknown or expired request id=#{id}")
170
197
  end
171
198
  end
172
- rescue JSON::ParserError
173
- # Skip non-JSONRPC lines in the output stream
199
+ rescue JSON::ParserError, EncodingError
200
+ # Skip non-JSONRPC or undecodable lines in the output stream so a single
201
+ # bad line cannot kill the reader thread
174
202
  end
175
203
 
176
204
  # List all prompts available from the MCP server
@@ -212,7 +240,7 @@ module MCPClient
212
240
  'jsonrpc' => '2.0',
213
241
  'id' => req_id,
214
242
  'method' => 'prompts/get',
215
- 'params' => { 'name' => prompt_name, 'arguments' => parameters }
243
+ 'params' => build_named_request_params(prompt_name, parameters)
216
244
  }
217
245
  send_request(req)
218
246
  res = wait_response(req_id)
@@ -308,6 +336,7 @@ module MCPClient
308
336
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
309
337
  def subscribe_resource(uri)
310
338
  ensure_initialized
339
+ require_capability!('resources', 'subscribe', method: 'resources/subscribe')
311
340
  req_id = next_id
312
341
  req = {
313
342
  'jsonrpc' => '2.0',
@@ -322,6 +351,8 @@ module MCPClient
322
351
  end
323
352
 
324
353
  true
354
+ rescue MCPClient::Errors::CapabilityError
355
+ raise
325
356
  rescue StandardError => e
326
357
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
327
358
  end
@@ -333,6 +364,7 @@ module MCPClient
333
364
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
334
365
  def unsubscribe_resource(uri)
335
366
  ensure_initialized
367
+ require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
336
368
  req_id = next_id
337
369
  req = {
338
370
  'jsonrpc' => '2.0',
@@ -347,6 +379,8 @@ module MCPClient
347
379
  end
348
380
 
349
381
  true
382
+ rescue MCPClient::Errors::CapabilityError
383
+ raise
350
384
  rescue StandardError => e
351
385
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
352
386
  end
@@ -391,7 +425,7 @@ module MCPClient
391
425
  'jsonrpc' => '2.0',
392
426
  'id' => req_id,
393
427
  'method' => 'tools/call',
394
- 'params' => { 'name' => tool_name, 'arguments' => parameters }
428
+ 'params' => build_named_request_params(tool_name, parameters)
395
429
  }
396
430
  send_request(req)
397
431
  res = wait_response(req_id)
@@ -412,6 +446,7 @@ module MCPClient
412
446
  # @raise [MCPClient::Errors::ServerError] if server returns an error
413
447
  def complete(ref:, argument:, context: nil)
414
448
  ensure_initialized
449
+ require_capability!('completions', method: 'completion/complete')
415
450
  req_id = next_id
416
451
  params = { 'ref' => ref, 'argument' => argument }
417
452
  params['context'] = context if context
@@ -428,6 +463,8 @@ module MCPClient
428
463
  end
429
464
 
430
465
  res.dig('result', 'completion') || { 'values' => [] }
466
+ rescue MCPClient::Errors::CapabilityError
467
+ raise
431
468
  rescue StandardError => e
432
469
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
433
470
  end
@@ -439,6 +476,7 @@ module MCPClient
439
476
  # @raise [MCPClient::Errors::ServerError] if server returns an error
440
477
  def log_level=(level)
441
478
  ensure_initialized
479
+ require_capability!('logging', method: 'logging/setLevel')
442
480
  req_id = next_id
443
481
  req = {
444
482
  'jsonrpc' => '2.0',
@@ -453,6 +491,8 @@ module MCPClient
453
491
  end
454
492
 
455
493
  res['result'] || {}
494
+ rescue MCPClient::Errors::CapabilityError
495
+ raise
456
496
  rescue StandardError => e
457
497
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
458
498
  end
@@ -524,18 +564,19 @@ module MCPClient
524
564
  # @param params [Hash] the elicitation parameters
525
565
  # @return [void]
526
566
  def handle_elicitation_create(request_id, params)
527
- # If no callback is registered, decline the request
567
+ # Without a callback there is no user to interact with: answer with a
568
+ # JSON-RPC error rather than fabricating a user "decline".
528
569
  unless @elicitation_request_callback
529
- @logger.warn('Received elicitation request but no callback registered, declining')
530
- send_elicitation_response(request_id, { 'action' => 'decline' })
570
+ @logger.warn('Received elicitation request but no callback registered')
571
+ send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
531
572
  return
532
573
  end
533
574
 
534
575
  # Call the registered callback
535
576
  result = @elicitation_request_callback.call(request_id, params)
536
577
 
537
- # Send the response back to the server
538
- send_elicitation_response(request_id, result)
578
+ # Send the response back to the server (echoing related-task _meta)
579
+ send_elicitation_response(request_id, merge_related_task_meta(result, params))
539
580
  end
540
581
 
541
582
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -553,8 +594,8 @@ module MCPClient
553
594
  # Call the registered callback
554
595
  result = @roots_list_request_callback.call(request_id, params)
555
596
 
556
- # Send the response back to the server
557
- send_roots_list_response(request_id, result)
597
+ # Send the response back to the server (echoing related-task _meta)
598
+ send_roots_list_response(request_id, merge_related_task_meta(result, params))
558
599
  end
559
600
 
560
601
  # Handle sampling/createMessage request from server (MCP 2025-11-25)
@@ -572,8 +613,8 @@ module MCPClient
572
613
  # Call the registered callback
573
614
  result = @sampling_request_callback.call(request_id, params)
574
615
 
575
- # Send the response back to the server
576
- send_sampling_response(request_id, result)
616
+ # Send the response back to the server (echoing related-task _meta)
617
+ send_sampling_response(request_id, merge_related_task_meta(result, params))
577
618
  end
578
619
 
579
620
  # Send roots/list response back to server (MCP 2025-06-18)
@@ -613,6 +654,14 @@ module MCPClient
613
654
  # @param result [Hash] the elicitation result (action and optional content)
614
655
  # @return [void]
615
656
  def send_elicitation_response(request_id, result)
657
+ # Error-shaped results become JSON-RPC error responses (e.g. -32602 for
658
+ # an undeclared elicitation mode), mirroring the sampling error path.
659
+ if result.is_a?(Hash) && result['error']
660
+ send_error_response(request_id, result['error']['code'] || -32_603,
661
+ result['error']['message'] || 'Elicitation error')
662
+ return
663
+ end
664
+
616
665
  response = {
617
666
  'jsonrpc' => '2.0',
618
667
  'id' => request_id,
@@ -673,17 +722,17 @@ module MCPClient
673
722
 
674
723
  # Clean up the server connection
675
724
  # Closes all stdio handles and terminates any running processes and threads
725
+ # following the MCP 2025-11-25 stdio shutdown sequence (basic/lifecycle.mdx):
726
+ # close stdin, wait for the server to exit, send SIGTERM if it does not exit
727
+ # within a reasonable time, then SIGKILL if it still does not exit.
676
728
  # @return [void]
677
729
  def cleanup
678
730
  return unless @stdin
679
731
 
680
732
  @stdin.close unless @stdin.closed?
733
+ terminate_server_process
681
734
  @stdout.close unless @stdout.closed?
682
735
  @stderr.close unless @stderr.closed?
683
- if @wait_thread&.alive?
684
- Process.kill('TERM', @wait_thread.pid)
685
- @wait_thread.join(1)
686
- end
687
736
  @reader_thread&.kill
688
737
  @stderr_thread&.kill
689
738
  rescue StandardError
@@ -696,5 +745,31 @@ module MCPClient
696
745
  end
697
746
  @stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
698
747
  end
748
+
749
+ # Terminate the spawned server process per the MCP 2025-11-25 stdio
750
+ # shutdown sequence (basic/lifecycle.mdx): stdin has already been closed,
751
+ # so wait for the process to exit on its own; if it does not exit within
752
+ # the grace period send SIGTERM, wait again, and finally send SIGKILL.
753
+ # @return [void]
754
+ def terminate_server_process
755
+ return unless @wait_thread
756
+ return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
757
+
758
+ signal_server_process('TERM')
759
+ return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
760
+
761
+ signal_server_process('KILL')
762
+ @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
763
+ end
764
+
765
+ # Send a signal to the server process, tolerating a process that has
766
+ # already exited or cannot be signalled.
767
+ # @param signal [String] signal name, e.g. 'TERM' or 'KILL'
768
+ # @return [void]
769
+ def signal_server_process(signal)
770
+ Process.kill(signal, @wait_thread.pid)
771
+ rescue Errno::ESRCH, Errno::EPERM => e
772
+ @logger.debug("Could not send SIG#{signal} to server process: #{e.class}")
773
+ end
699
774
  end
700
775
  end
@@ -22,10 +22,11 @@ module MCPClient
22
22
 
23
23
  # Parse a Streamable HTTP JSON-RPC response (JSON or SSE format)
24
24
  # @param response [Faraday::Response] the HTTP response
25
+ # @param request [Hash, nil] the originating JSON-RPC request, used to match the response by id
25
26
  # @return [Hash] the parsed result
26
27
  # @raise [MCPClient::Errors::TransportError] if parsing fails
27
28
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
28
- def parse_response(response)
29
+ def parse_response(response, request = nil)
29
30
  body = response.body
30
31
  content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
31
32
  content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
@@ -36,7 +37,7 @@ module MCPClient
36
37
  # Determine response format based on Content-Type header per MCP 2025 spec
37
38
  data = if content_type.include?('text/event-stream')
38
39
  # Parse SSE-formatted response for streaming
39
- parse_sse_response(body)
40
+ parse_sse_response(body, request && request['id'])
40
41
  else
41
42
  # Parse regular JSON response (default for Streamable HTTP)
42
43
  JSON.parse(body)
@@ -47,50 +48,175 @@ module MCPClient
47
48
  raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
48
49
  end
49
50
 
50
- # Parse Server-Sent Event formatted response with event ID tracking
51
+ # Parse a Server-Sent Event formatted response body.
52
+ #
53
+ # Per MCP 2025-11-25, the server MAY send JSON-RPC requests and
54
+ # notifications on the POST response stream before the response, and MAY
55
+ # send priming events carrying only an event id. Every interleaved server
56
+ # message is dispatched exactly like on the GET events stream; the
57
+ # JSON-RPC response matching the originating request id is returned.
58
+ #
51
59
  # @param sse_body [String] the SSE formatted response body
52
- # @return [Hash] the parsed JSON data
53
- # @raise [MCPClient::Errors::TransportError] if no data found in SSE response
54
- def parse_sse_response(sse_body)
55
- # Extract JSON data from SSE format, processing events separately
56
- # SSE format: event: message\nid: 123\ndata: {...}\n\n
60
+ # @param request_id [Integer, String, nil] id of the originating request
61
+ # @return [Hash] the parsed JSON-RPC response
62
+ # @raise [MCPClient::Errors::TransportError] if no response is found
63
+ def parse_sse_response(sse_body, request_id = nil)
64
+ events, retry_ms = extract_sse_events(sse_body)
65
+
66
+ raise MCPClient::Errors::TransportError, 'No data found in SSE response' if events.empty?
67
+
68
+ responses, saw_invalid_json = route_sse_events(events)
69
+ matched = select_sse_response(responses, request_id)
70
+ return matched if matched
71
+
72
+ if saw_invalid_json
73
+ raise MCPClient::Errors::TransportError,
74
+ 'Invalid JSON response from server: SSE stream contained no valid JSON-RPC response'
75
+ end
76
+
77
+ resume_or_fail(events, request_id, retry_ms)
78
+ end
79
+
80
+ # SEP-1699 polling pattern: the server MAY close the POST stream before
81
+ # delivering the response. When a cursor was received, resume via HTTP
82
+ # GET with Last-Event-ID instead of re-POSTing the (possibly
83
+ # non-idempotent) request.
84
+ # @param events [Array<Hash>] parsed SSE events
85
+ # @param request_id [Integer, String, nil] id of the originating request
86
+ # @param retry_ms [Integer, nil] retry directive received on THIS stream
87
+ # @return [Hash] the replayed JSON-RPC response
88
+ # @raise [MCPClient::Errors::ServerError] when resumption fails
89
+ # @raise [MCPClient::Errors::TransportError] when no cursor was received
90
+ def resume_or_fail(events, request_id, retry_ms = nil)
91
+ cursor = events.reverse.find { |e| e[:id] && !e[:id].empty? }&.dig(:id)
92
+ if request_id && cursor
93
+ # Resume with THIS stream's cursor and retry directive (both are
94
+ # per-stream), not the shared @last_event_id / @sse_retry_ms which a
95
+ # concurrent stream may have moved between parsing and resumption.
96
+ resumed = resume_response_via_get(request_id, cursor, retry_ms)
97
+ return resumed if resumed
98
+
99
+ # Non-retryable: the request may already be executing server-side,
100
+ # so a blind re-POST could run a non-idempotent operation twice.
101
+ raise MCPClient::Errors::ServerError,
102
+ 'SSE stream closed before delivering the response and resumption via GET failed'
103
+ end
104
+
105
+ raise MCPClient::Errors::TransportError, 'No JSON-RPC response found in SSE response'
106
+ end
107
+
108
+ # Split an SSE body into events. An event without an explicit `event:`
109
+ # field has the default type "message" per the SSE specification; events
110
+ # carrying only an id (priming events) are kept so their id is tracked.
111
+ # @param sse_body [String] the SSE formatted response body
112
+ # @return [Array(Array<Hash>, Integer, nil)] parsed events and the last
113
+ # retry directive (ms) received on this stream, if any
114
+ def extract_sse_events(sse_body)
57
115
  events = []
58
- current_event = { type: nil, data_lines: [], id: nil }
116
+ retry_ms = nil
117
+ current_event = { type: 'message', data_lines: [], id: nil }
59
118
 
60
119
  sse_body.lines.each do |line|
61
120
  line = line.strip
62
121
 
63
122
  if line.empty?
64
123
  # Empty line marks end of an event
65
- events << current_event.dup if current_event[:type] && !current_event[:data_lines].empty?
66
- current_event = { type: nil, data_lines: [], id: nil }
124
+ events << current_event.dup if sse_event_present?(current_event)
125
+ current_event = { type: 'message', data_lines: [], id: nil }
67
126
  elsif line.start_with?('event:')
68
127
  current_event[:type] = line.sub(/^event:\s*/, '').strip
69
128
  elsif line.start_with?('data:')
70
129
  current_event[:data_lines] << line.sub(/^data:\s*/, '').strip
71
130
  elsif line.start_with?('id:')
72
131
  current_event[:id] = line.sub(/^id:\s*/, '').strip
132
+ elsif line.start_with?('retry:')
133
+ # SEP-1699: the client MUST respect the server's retry directive.
134
+ # Track it locally for this stream's resumption; the shared ivar is
135
+ # only a hint for the general events loop.
136
+ raw = line.sub(/^retry:\s*/, '').strip
137
+ if raw.match?(/\A\d+\z/)
138
+ retry_ms = raw.to_i
139
+ @sse_retry_ms = retry_ms
140
+ end
73
141
  end
74
142
  end
75
143
 
76
144
  # Handle last event if no trailing empty line
77
- events << current_event if current_event[:type] && !current_event[:data_lines].empty?
145
+ events << current_event if sse_event_present?(current_event)
146
+ [events, retry_ms]
147
+ end
78
148
 
79
- # Find the first 'message' event which contains the JSON-RPC response
80
- message_event = events.find { |e| e[:type] == 'message' }
149
+ # @param event [Hash] a parsed SSE event
150
+ # @return [Boolean] whether the event carries any data or id
151
+ def sse_event_present?(event)
152
+ (event[:id] && !event[:id].empty?) || !event[:data_lines].empty?
153
+ end
154
+
155
+ # Track event ids for resumability, dispatch interleaved server messages
156
+ # (requests, notifications, pings) and collect response candidates.
157
+ # @param events [Array<Hash>] parsed SSE events
158
+ # @return [Array(Array<Hash>, Boolean)] response candidates and whether invalid JSON was seen
159
+ def route_sse_events(events)
160
+ responses = []
161
+ saw_invalid_json = false
162
+
163
+ events.each do |event|
164
+ if event[:id] && !event[:id].empty?
165
+ @mutex.synchronize { @last_event_id = event[:id] }
166
+ @logger.debug("Tracking event ID for resumability: #{event[:id]}")
167
+ end
168
+ next unless event[:type] == 'message'
169
+
170
+ message = parse_sse_event_data(event[:data_lines].join("\n"))
171
+ saw_invalid_json = true if message == :invalid
172
+ next unless message.is_a?(Hash)
173
+
174
+ if message['method']
175
+ dispatch_server_message(message)
176
+ else
177
+ responses << message
178
+ end
179
+ end
81
180
 
82
- raise MCPClient::Errors::TransportError, 'No data found in SSE response' unless message_event
83
- raise MCPClient::Errors::TransportError, 'No data found in message event' if message_event[:data_lines].empty?
181
+ [responses, saw_invalid_json]
182
+ end
183
+
184
+ # Parse the data payload of a single SSE event.
185
+ # @param json_data [String] the joined data lines
186
+ # @return [Hash, Symbol, nil] the parsed message, :invalid, or nil for empty/non-object data
187
+ def parse_sse_event_data(json_data)
188
+ return nil if json_data.empty?
189
+
190
+ message = JSON.parse(json_data)
191
+ return message if message.is_a?(Hash)
192
+
193
+ @logger.warn("Skipping non-object JSON-RPC message in SSE event: #{message.inspect}")
194
+ nil
195
+ rescue JSON::ParserError => e
196
+ @logger.warn("Skipping invalid JSON in SSE event: #{e.message}")
197
+ :invalid
198
+ end
84
199
 
85
- # Track the event ID for resumability
86
- if message_event[:id] && !message_event[:id].empty?
87
- @last_event_id = message_event[:id]
88
- @logger.debug("Tracking event ID for resumability: #{message_event[:id]}")
200
+ # Choose the JSON-RPC response answering the originating request.
201
+ # @param responses [Array<Hash>] response candidates from the stream
202
+ # @param request_id [Integer, String, nil] id of the originating request
203
+ # @return [Hash, nil] the selected response, if any
204
+ def select_sse_response(responses, request_id)
205
+ matched = if request_id.nil?
206
+ responses.first
207
+ else
208
+ responses.find { |msg| msg['id'] == request_id || msg['id'].to_s == request_id.to_s }
209
+ end
210
+
211
+ if matched.nil? && responses.length == 1
212
+ matched = responses.first
213
+ @logger.warn(
214
+ "SSE response id #{matched['id'].inspect} does not match request id #{request_id.inspect}; " \
215
+ 'accepting the only response on the stream'
216
+ )
89
217
  end
90
218
 
91
- # Join multiline data fields according to SSE spec
92
- json_data = message_event[:data_lines].join("\n")
93
- JSON.parse(json_data)
219
+ matched
94
220
  end
95
221
  end
96
222
  end