ruby-mcp-client 1.1.0 → 2.1.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +216 -10
  3. data/lib/mcp_client/auth/oauth_provider.rb +325 -27
  4. data/lib/mcp_client/auth.rb +38 -11
  5. data/lib/mcp_client/client.rb +523 -150
  6. data/lib/mcp_client/elicitation_validator.rb +99 -13
  7. data/lib/mcp_client/errors.rb +43 -1
  8. data/lib/mcp_client/http_transport_base.rb +254 -41
  9. data/lib/mcp_client/json_rpc_common.rb +196 -14
  10. data/lib/mcp_client/oauth_client.rb +8 -3
  11. data/lib/mcp_client/prompt.rb +17 -2
  12. data/lib/mcp_client/resource.rb +13 -2
  13. data/lib/mcp_client/resource_content.rb +8 -3
  14. data/lib/mcp_client/resource_link.rb +14 -3
  15. data/lib/mcp_client/resource_template.rb +13 -2
  16. data/lib/mcp_client/root.rb +61 -7
  17. data/lib/mcp_client/schema_validator.rb +329 -0
  18. data/lib/mcp_client/server_base.rb +66 -0
  19. data/lib/mcp_client/server_factory.rb +4 -1
  20. data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
  21. data/lib/mcp_client/server_http.rb +18 -12
  22. data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
  23. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  24. data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
  25. data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
  26. data/lib/mcp_client/server_sse.rb +132 -35
  27. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
  28. data/lib/mcp_client/server_stdio.rb +98 -20
  29. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
  30. data/lib/mcp_client/server_streamable_http.rb +472 -108
  31. data/lib/mcp_client/tool.rb +16 -3
  32. data/lib/mcp_client/version.rb +6 -1
  33. data/lib/mcp_client.rb +9 -1
  34. metadata +5 -6
@@ -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
@@ -140,7 +160,14 @@ module MCPClient
140
160
  # @return [void]
141
161
  def handle_line(line)
142
162
  msg = JSON.parse(line)
143
- @logger.debug("Received line: #{line.chomp}")
163
+ @logger.debug("Received line: #{describe_jsonrpc_message(msg)}")
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
144
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')
@@ -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
@@ -502,8 +542,11 @@ module MCPClient
502
542
  send_error_response(request_id, -32_601, "Method not found: #{method}")
503
543
  end
504
544
  rescue StandardError => e
545
+ # The exception message is host-internal (file paths, connection
546
+ # strings, library internals): log it locally, answer the peer with a
547
+ # constant message, matching the SSE and Streamable HTTP transports.
505
548
  @logger.error("Error handling server request: #{e.message}")
506
- send_error_response(request_id, -32_603, "Internal error: #{e.message}")
549
+ send_error_response(request_id, -32_603, 'Internal error')
507
550
  end
508
551
 
509
552
  # Handle a server-initiated ping request (MCP ping utility)
@@ -524,18 +567,19 @@ module MCPClient
524
567
  # @param params [Hash] the elicitation parameters
525
568
  # @return [void]
526
569
  def handle_elicitation_create(request_id, params)
527
- # If no callback is registered, decline the request
570
+ # Without a callback there is no user to interact with: answer with a
571
+ # JSON-RPC error rather than fabricating a user "decline".
528
572
  unless @elicitation_request_callback
529
- @logger.warn('Received elicitation request but no callback registered, declining')
530
- send_elicitation_response(request_id, { 'action' => 'decline' })
573
+ @logger.warn('Received elicitation request but no callback registered')
574
+ send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
531
575
  return
532
576
  end
533
577
 
534
578
  # Call the registered callback
535
579
  result = @elicitation_request_callback.call(request_id, params)
536
580
 
537
- # Send the response back to the server
538
- send_elicitation_response(request_id, result)
581
+ # Send the response back to the server (echoing related-task _meta)
582
+ send_elicitation_response(request_id, merge_related_task_meta(result, params))
539
583
  end
540
584
 
541
585
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -553,8 +597,8 @@ module MCPClient
553
597
  # Call the registered callback
554
598
  result = @roots_list_request_callback.call(request_id, params)
555
599
 
556
- # Send the response back to the server
557
- send_roots_list_response(request_id, result)
600
+ # Send the response back to the server (echoing related-task _meta)
601
+ send_roots_list_response(request_id, merge_related_task_meta(result, params))
558
602
  end
559
603
 
560
604
  # Handle sampling/createMessage request from server (MCP 2025-11-25)
@@ -572,8 +616,8 @@ module MCPClient
572
616
  # Call the registered callback
573
617
  result = @sampling_request_callback.call(request_id, params)
574
618
 
575
- # Send the response back to the server
576
- send_sampling_response(request_id, result)
619
+ # Send the response back to the server (echoing related-task _meta)
620
+ send_sampling_response(request_id, merge_related_task_meta(result, params))
577
621
  end
578
622
 
579
623
  # Send roots/list response back to server (MCP 2025-06-18)
@@ -613,6 +657,14 @@ module MCPClient
613
657
  # @param result [Hash] the elicitation result (action and optional content)
614
658
  # @return [void]
615
659
  def send_elicitation_response(request_id, result)
660
+ # Error-shaped results become JSON-RPC error responses (e.g. -32602 for
661
+ # an undeclared elicitation mode), mirroring the sampling error path.
662
+ if result.is_a?(Hash) && result['error']
663
+ send_error_response(request_id, result['error']['code'] || -32_603,
664
+ result['error']['message'] || 'Elicitation error')
665
+ return
666
+ end
667
+
616
668
  response = {
617
669
  'jsonrpc' => '2.0',
618
670
  'id' => request_id,
@@ -645,7 +697,7 @@ module MCPClient
645
697
  json = JSON.generate(message)
646
698
  @stdin.puts(json)
647
699
  @stdin.flush
648
- @logger.debug("Sent message: #{json}")
700
+ @logger.debug("Sent message: #{describe_jsonrpc_message(message)}")
649
701
  rescue StandardError => e
650
702
  @logger.error("Error sending message: #{e.message}")
651
703
  end
@@ -673,17 +725,17 @@ module MCPClient
673
725
 
674
726
  # Clean up the server connection
675
727
  # Closes all stdio handles and terminates any running processes and threads
728
+ # following the MCP 2025-11-25 stdio shutdown sequence (basic/lifecycle.mdx):
729
+ # close stdin, wait for the server to exit, send SIGTERM if it does not exit
730
+ # within a reasonable time, then SIGKILL if it still does not exit.
676
731
  # @return [void]
677
732
  def cleanup
678
733
  return unless @stdin
679
734
 
680
735
  @stdin.close unless @stdin.closed?
736
+ terminate_server_process
681
737
  @stdout.close unless @stdout.closed?
682
738
  @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
739
  @reader_thread&.kill
688
740
  @stderr_thread&.kill
689
741
  rescue StandardError
@@ -696,5 +748,31 @@ module MCPClient
696
748
  end
697
749
  @stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
698
750
  end
751
+
752
+ # Terminate the spawned server process per the MCP 2025-11-25 stdio
753
+ # shutdown sequence (basic/lifecycle.mdx): stdin has already been closed,
754
+ # so wait for the process to exit on its own; if it does not exit within
755
+ # the grace period send SIGTERM, wait again, and finally send SIGKILL.
756
+ # @return [void]
757
+ def terminate_server_process
758
+ return unless @wait_thread
759
+ return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
760
+
761
+ signal_server_process('TERM')
762
+ return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
763
+
764
+ signal_server_process('KILL')
765
+ @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
766
+ end
767
+
768
+ # Send a signal to the server process, tolerating a process that has
769
+ # already exited or cannot be signalled.
770
+ # @param signal [String] signal name, e.g. 'TERM' or 'KILL'
771
+ # @return [void]
772
+ def signal_server_process(signal)
773
+ Process.kill(signal, @wait_thread.pid)
774
+ rescue Errno::ESRCH, Errno::EPERM => e
775
+ @logger.debug("Could not send SIG#{signal} to server process: #{e.class}")
776
+ end
699
777
  end
700
778
  end
@@ -12,31 +12,65 @@ module MCPClient
12
12
  module JsonRpcTransport
13
13
  include HttpTransportBase
14
14
 
15
+ # Default ceiling on the expanded size of a gzip-encoded response body.
16
+ # The peer controls the compression ratio, so without a bound a tiny
17
+ # compressed response ("gzip bomb") could expand to an arbitrarily large
18
+ # string and exhaust host memory before JSON parsing.
19
+ #
20
+ # Hosts that legitimately exchange very large payloads (e.g. base64
21
+ # resource blobs or audio) can raise it per server with the
22
+ # max_decompressed_body_bytes option, so that whether a response is
23
+ # accepted does not depend on the server's choice to gzip it.
24
+ MAX_DECOMPRESSED_BODY_BYTES = 64 * 1024 * 1024
25
+ DECOMPRESS_CHUNK_BYTES = 64 * 1024
26
+
15
27
  private
16
28
 
29
+ # Whether a server-supplied SSE event id may be retained as the
30
+ # resumption cursor: non-empty, bounded, and safe to place in an HTTP
31
+ # header. Shared by every SSE parsing path (GET events stream, POST
32
+ # response stream, and resumed GET), since all three feed the same
33
+ # Last-Event-ID header.
34
+ # @param id [String, nil] the raw id field
35
+ # @return [Boolean]
36
+ def retainable_event_id?(id)
37
+ return false if id.nil? || id.empty?
38
+
39
+ if id.length > MAX_EVENT_ID_LENGTH
40
+ @logger.warn("Ignoring oversized SSE event id (#{id.length} chars)")
41
+ return false
42
+ end
43
+
44
+ return true if id.match?(EVENT_ID_PATTERN)
45
+
46
+ @logger.warn('Ignoring SSE event id with characters illegal in a header value')
47
+ false
48
+ end
49
+
17
50
  # Log HTTP response for Streamable HTTP
18
51
  # @param response [Faraday::Response] the HTTP response
19
52
  def log_response(response)
20
- @logger.debug("Received Streamable HTTP response: #{response.status} #{response.body}")
53
+ @logger.debug("Received Streamable HTTP response: #{response.status} (#{describe_body_size(response.body)})")
21
54
  end
22
55
 
23
56
  # Parse a Streamable HTTP JSON-RPC response (JSON or SSE format)
24
57
  # @param response [Faraday::Response] the HTTP response
58
+ # @param request [Hash, nil] the originating JSON-RPC request, used to match the response by id
25
59
  # @return [Hash] the parsed result
26
60
  # @raise [MCPClient::Errors::TransportError] if parsing fails
27
61
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
28
- def parse_response(response)
62
+ def parse_response(response, request = nil)
29
63
  body = response.body
30
64
  content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
31
65
  content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
32
66
 
33
- body = Zlib::GzipReader.new(StringIO.new(body)).read if content_encoding.include?('gzip')
67
+ body = decompress_gzip(body) if content_encoding.include?('gzip')
34
68
  body = body&.strip
35
69
 
36
70
  # Determine response format based on Content-Type header per MCP 2025 spec
37
71
  data = if content_type.include?('text/event-stream')
38
72
  # Parse SSE-formatted response for streaming
39
- parse_sse_response(body)
73
+ parse_sse_response(body, request && request['id'])
40
74
  else
41
75
  # Parse regular JSON response (default for Streamable HTTP)
42
76
  JSON.parse(body)
@@ -44,53 +78,215 @@ module MCPClient
44
78
 
45
79
  process_jsonrpc_response(data)
46
80
  rescue JSON::ParserError => e
47
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
81
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
82
+ end
83
+
84
+ # Incrementally decompress a gzip response body, aborting once the
85
+ # expanded output exceeds the configured ceiling.
86
+ # @param body [String] the gzip-compressed response body
87
+ # @return [String] the decompressed body
88
+ # @raise [MCPClient::Errors::ResponseTooLargeError] if the expansion limit is exceeded
89
+ def decompress_gzip(body)
90
+ limit = max_decompressed_body_bytes
91
+ reader = Zlib::GzipReader.new(StringIO.new(body))
92
+ decompressed = +''
93
+ while (chunk = reader.read(DECOMPRESS_CHUNK_BYTES))
94
+ decompressed << chunk
95
+ next unless decompressed.bytesize > limit
96
+
97
+ # ResponseTooLargeError (not a plain TransportError) so with_retry
98
+ # does not re-POST a request the server has already executed.
99
+ raise MCPClient::Errors::ResponseTooLargeError,
100
+ "Gzip response expanded beyond #{limit} bytes"
101
+ end
102
+ decompressed
103
+ ensure
104
+ reader&.close
105
+ end
106
+
107
+ # Configured ceiling for decompressed response bodies.
108
+ # @return [Integer] positive byte limit
109
+ def max_decompressed_body_bytes
110
+ configured = defined?(@max_decompressed_body_bytes) ? @max_decompressed_body_bytes : nil
111
+ configured || MAX_DECOMPRESSED_BODY_BYTES
112
+ end
113
+
114
+ # Parse a Server-Sent Event formatted response body.
115
+ #
116
+ # Per MCP 2025-11-25, the server MAY send JSON-RPC requests and
117
+ # notifications on the POST response stream before the response, and MAY
118
+ # send priming events carrying only an event id. Every interleaved server
119
+ # message is dispatched exactly like on the GET events stream; the
120
+ # JSON-RPC response matching the originating request id is returned.
121
+ #
122
+ # @param sse_body [String] the SSE formatted response body
123
+ # @param request_id [Integer, String, nil] id of the originating request
124
+ # @return [Hash] the parsed JSON-RPC response
125
+ # @raise [MCPClient::Errors::TransportError] if no response is found
126
+ def parse_sse_response(sse_body, request_id = nil)
127
+ events, retry_ms = extract_sse_events(sse_body)
128
+
129
+ raise MCPClient::Errors::TransportError, 'No data found in SSE response' if events.empty?
130
+
131
+ responses, saw_invalid_json = route_sse_events(events)
132
+ matched = select_sse_response(responses, request_id)
133
+ return matched if matched
134
+
135
+ if saw_invalid_json
136
+ raise MCPClient::Errors::TransportError,
137
+ 'Invalid JSON response from server: SSE stream contained no valid JSON-RPC response'
138
+ end
139
+
140
+ resume_or_fail(events, request_id, retry_ms)
48
141
  end
49
142
 
50
- # Parse Server-Sent Event formatted response with event ID tracking
143
+ # SEP-1699 polling pattern: the server MAY close the POST stream before
144
+ # delivering the response. When a cursor was received, resume via HTTP
145
+ # GET with Last-Event-ID instead of re-POSTing the (possibly
146
+ # non-idempotent) request.
147
+ # @param events [Array<Hash>] parsed SSE events
148
+ # @param request_id [Integer, String, nil] id of the originating request
149
+ # @param retry_ms [Integer, nil] retry directive received on THIS stream
150
+ # @return [Hash] the replayed JSON-RPC response
151
+ # @raise [MCPClient::Errors::ServerError] when resumption fails
152
+ # @raise [MCPClient::Errors::TransportError] when no cursor was received
153
+ def resume_or_fail(events, request_id, retry_ms = nil)
154
+ # Only a validated id may become a cursor: it is sent back as a
155
+ # Last-Event-ID header on the resumption GET.
156
+ cursor = events.reverse.find { |e| retainable_event_id?(e[:id]) }&.dig(:id)
157
+ if request_id && cursor
158
+ # Resume with THIS stream's cursor and retry directive (both are
159
+ # per-stream), not the shared @last_event_id / @sse_retry_ms which a
160
+ # concurrent stream may have moved between parsing and resumption.
161
+ resumed = resume_response_via_get(request_id, cursor, retry_ms)
162
+ return resumed if resumed
163
+
164
+ # Non-retryable: the request may already be executing server-side,
165
+ # so a blind re-POST could run a non-idempotent operation twice.
166
+ raise MCPClient::Errors::ServerError,
167
+ 'SSE stream closed before delivering the response and resumption via GET failed'
168
+ end
169
+
170
+ raise MCPClient::Errors::TransportError, 'No JSON-RPC response found in SSE response'
171
+ end
172
+
173
+ # Split an SSE body into events. An event without an explicit `event:`
174
+ # field has the default type "message" per the SSE specification; events
175
+ # carrying only an id (priming events) are kept so their id is tracked.
51
176
  # @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
177
+ # @return [Array(Array<Hash>, Integer, nil)] parsed events and the last
178
+ # retry directive (ms) received on this stream, if any
179
+ def extract_sse_events(sse_body)
57
180
  events = []
58
- current_event = { type: nil, data_lines: [], id: nil }
181
+ retry_ms = nil
182
+ current_event = { type: 'message', data_lines: [], id: nil }
59
183
 
60
184
  sse_body.lines.each do |line|
61
185
  line = line.strip
62
186
 
63
187
  if line.empty?
64
188
  # 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 }
189
+ events << current_event.dup if sse_event_present?(current_event)
190
+ current_event = { type: 'message', data_lines: [], id: nil }
67
191
  elsif line.start_with?('event:')
68
192
  current_event[:type] = line.sub(/^event:\s*/, '').strip
69
193
  elsif line.start_with?('data:')
70
194
  current_event[:data_lines] << line.sub(/^data:\s*/, '').strip
71
195
  elsif line.start_with?('id:')
72
196
  current_event[:id] = line.sub(/^id:\s*/, '').strip
197
+ elsif line.start_with?('retry:')
198
+ # SEP-1699: the client MUST respect the server's retry directive.
199
+ # Track it locally for this stream's resumption; the shared ivar is
200
+ # only a hint for the general events loop.
201
+ raw = line.sub(/^retry:\s*/, '').strip
202
+ if raw.match?(/\A\d+\z/)
203
+ retry_ms = raw.to_i
204
+ @sse_retry_ms = retry_ms
205
+ end
73
206
  end
74
207
  end
75
208
 
76
209
  # Handle last event if no trailing empty line
77
- events << current_event if current_event[:type] && !current_event[:data_lines].empty?
210
+ events << current_event if sse_event_present?(current_event)
211
+ [events, retry_ms]
212
+ end
213
+
214
+ # @param event [Hash] a parsed SSE event
215
+ # @return [Boolean] whether the event carries any data or id
216
+ def sse_event_present?(event)
217
+ (event[:id] && !event[:id].empty?) || !event[:data_lines].empty?
218
+ end
219
+
220
+ # Track event ids for resumability, dispatch interleaved server messages
221
+ # (requests, notifications, pings) and collect response candidates.
222
+ # @param events [Array<Hash>] parsed SSE events
223
+ # @return [Array(Array<Hash>, Boolean)] response candidates and whether invalid JSON was seen
224
+ def route_sse_events(events)
225
+ responses = []
226
+ saw_invalid_json = false
227
+
228
+ events.each do |event|
229
+ if event[:id] && !event[:id].empty?
230
+ # The POST SSE stream is peer-controlled like the GET one, so its
231
+ # ids get the same bound/charset check before being retained or
232
+ # echoed in a Last-Event-ID header.
233
+ @mutex.synchronize { @last_event_id = event[:id] } if retainable_event_id?(event[:id])
234
+ @logger.debug("Tracking event ID for resumability: #{event[:id]}")
235
+ end
236
+ next unless event[:type] == 'message'
78
237
 
79
- # Find the first 'message' event which contains the JSON-RPC response
80
- message_event = events.find { |e| e[:type] == 'message' }
238
+ message = parse_sse_event_data(event[:data_lines].join("\n"))
239
+ saw_invalid_json = true if message == :invalid
240
+ next unless message.is_a?(Hash)
241
+
242
+ if message['method']
243
+ dispatch_server_message(message)
244
+ else
245
+ responses << message
246
+ end
247
+ end
248
+
249
+ [responses, saw_invalid_json]
250
+ end
251
+
252
+ # Parse the data payload of a single SSE event.
253
+ # @param json_data [String] the joined data lines
254
+ # @return [Hash, Symbol, nil] the parsed message, :invalid, or nil for empty/non-object data
255
+ def parse_sse_event_data(json_data)
256
+ return nil if json_data.empty?
257
+
258
+ message = JSON.parse(json_data)
259
+ return message if message.is_a?(Hash)
260
+
261
+ # Type only: the value is peer-controlled payload and may carry tool
262
+ # arguments, results or elicitation content.
263
+ @logger.warn("Skipping non-object JSON-RPC message in SSE event (#{message.class})")
264
+ nil
265
+ rescue JSON::ParserError => e
266
+ @logger.warn("Skipping invalid JSON in SSE event: #{describe_parse_error(e, json_data)}")
267
+ :invalid
268
+ end
81
269
 
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?
270
+ # Choose the JSON-RPC response answering the originating request.
271
+ # @param responses [Array<Hash>] response candidates from the stream
272
+ # @param request_id [Integer, String, nil] id of the originating request
273
+ # @return [Hash, nil] the selected response, if any
274
+ def select_sse_response(responses, request_id)
275
+ matched = if request_id.nil?
276
+ responses.first
277
+ else
278
+ responses.find { |msg| msg['id'] == request_id || msg['id'].to_s == request_id.to_s }
279
+ end
84
280
 
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]}")
281
+ if matched.nil? && responses.length == 1
282
+ matched = responses.first
283
+ @logger.warn(
284
+ "SSE response id #{matched['id'].inspect} does not match request id #{request_id.inspect}; " \
285
+ 'accepting the only response on the stream'
286
+ )
89
287
  end
90
288
 
91
- # Join multiline data fields according to SSE spec
92
- json_data = message_event[:data_lines].join("\n")
93
- JSON.parse(json_data)
289
+ matched
94
290
  end
95
291
  end
96
292
  end