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
@@ -33,6 +33,52 @@ module MCPClient
33
33
  SSE_MAX_RECONNECT_DELAY = 30 # Maximum reconnect delay in seconds
34
34
  THREAD_JOIN_TIMEOUT = 5 # Timeout for thread cleanup
35
35
 
36
+ # Floor for the delay between resumption GETs. SEP-1699's polling pattern
37
+ # wants fast reconnects, so this is far smaller than the events-stream
38
+ # floor — it only prevents a peer-supplied "retry: 0" from turning the
39
+ # deadline window into a back-to-back request loop.
40
+ MIN_RESUMPTION_RECONNECT_DELAY = 0.01
41
+
42
+ # Maximum length of a server-supplied SSE event id retained as the
43
+ # resumption cursor. The id is echoed in the Last-Event-ID header of
44
+ # subsequent requests, so an unbounded value means unbounded retained
45
+ # memory and oversized outbound headers.
46
+ MAX_EVENT_ID_LENGTH = 1024
47
+
48
+ # Characters allowed in a retained event id: printable ASCII, since the
49
+ # value becomes an HTTP header value. Notably excludes CR/LF. The range
50
+ # starts at 0x20 because a space is legal inside a field value, and
51
+ # rejecting ids like "cursor 42" would silently strand resumption on a
52
+ # stale cursor.
53
+ EVENT_ID_PATTERN = /\A[\x20-\x7E]+\z/
54
+
55
+ # Ceiling on concurrent threads POSTing server-initiated responses
56
+ # (pongs, roots/sampling/elicitation replies, error responses). Each
57
+ # server request on the events stream costs one blocking HTTP POST in
58
+ # its own thread; without a bound, a peer flooding requests could
59
+ # accumulate threads and connections until the host is exhausted.
60
+ # Responses beyond the budget are dropped, with saturation logged at most
61
+ # once per SATURATION_LOG_INTERVAL seconds.
62
+ MAX_CONCURRENT_RESPONSE_POSTS = 8
63
+
64
+ # Minimum gap between "response budget saturated" warnings
65
+ SATURATION_LOG_INTERVAL = 5
66
+
67
+ # Floor for server-supplied retry directives on the long-lived events
68
+ # stream. The directive is peer-controlled: honoring "retry: 0" literally
69
+ # would let a hostile server that closes every stream drive a tight
70
+ # reconnect loop (sustained CPU/TLS/connection churn). Waiting longer
71
+ # than the directive stays SEP-1699 compliant — the retry field is a
72
+ # lower bound on the reconnect delay, not an exact schedule.
73
+ MIN_EVENTS_RECONNECT_DELAY = 0.1
74
+
75
+ # Maximum bytes an SSE parse buffer (events stream or resumption GET) may
76
+ # hold while waiting for an event terminator. The stream is
77
+ # peer-controlled: without a cap, a hostile server could withhold the
78
+ # blank-line delimiter forever and grow the buffer until the host runs
79
+ # out of memory. Generous enough for any legitimate JSON-RPC event.
80
+ MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
81
+
36
82
  # @!attribute [r] base_url
37
83
  # @return [String] The base URL of the MCP server
38
84
  # @!attribute [r] endpoint
@@ -98,6 +144,7 @@ module MCPClient
98
144
 
99
145
  @read_timeout = opts[:read_timeout]
100
146
  @faraday_config = opts[:faraday_config]
147
+ @max_decompressed_body_bytes = validate_decompression_limit(opts[:max_decompressed_body_bytes])
101
148
  @tools = nil
102
149
  @tools_data = nil
103
150
  @prompts = nil
@@ -111,12 +158,20 @@ module MCPClient
111
158
  @http_conn = nil
112
159
  @session_id = nil
113
160
  @last_event_id = nil
161
+ @sse_retry_ms = nil
162
+ @pending_stream_responses = {}
163
+ @response_post_count = 0
164
+ # Saturation bookkeeping for the response-POST budget
165
+ @dropped_response_posts = 0
166
+ @last_saturation_log_at = nil
114
167
  @oauth_provider = opts[:oauth_provider]
115
168
 
116
169
  # SSE events connection state
117
170
  @events_connection = nil
118
171
  @events_thread = nil
119
- @buffer = '' # Buffer for partial SSE event data
172
+ @buffer = +'' # Buffer for partial SSE event data
173
+ # How much of @buffer has already been searched for an event terminator
174
+ @buffer_scanned = 0
120
175
  @elicitation_request_callback = nil # MCP 2025-06-18
121
176
  @roots_list_request_callback = nil # MCP 2025-06-18
122
177
  @sampling_request_callback = nil # MCP 2025-11-25
@@ -196,11 +251,7 @@ module MCPClient
196
251
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
197
252
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
198
253
  def call_tool(tool_name, parameters)
199
- rpc_request('tools/call', {
200
- name: tool_name,
201
- arguments: parameters.except(:_meta),
202
- **parameters.slice(:_meta)
203
- })
254
+ rpc_request('tools/call', build_named_request_params(tool_name, parameters))
204
255
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
205
256
  # Re-raise connection/transport errors directly to match test expectations
206
257
  raise
@@ -226,11 +277,14 @@ module MCPClient
226
277
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
227
278
  # @raise [MCPClient::Errors::ServerError] if server returns an error
228
279
  def complete(ref:, argument:, context: nil)
280
+ ensure_connected
281
+ require_capability!('completions', method: 'completion/complete')
229
282
  params = { ref: ref, argument: argument }
230
283
  params[:context] = context if context
231
284
  result = rpc_request('completion/complete', params)
232
285
  result['completion'] || { 'values' => [] }
233
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
286
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
287
+ MCPClient::Errors::CapabilityError
234
288
  raise
235
289
  rescue StandardError => e
236
290
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
@@ -242,8 +296,11 @@ module MCPClient
242
296
  # @return [Hash] empty result on success
243
297
  # @raise [MCPClient::Errors::ServerError] if server returns an error
244
298
  def log_level=(level)
299
+ ensure_connected
300
+ require_capability!('logging', method: 'logging/setLevel')
245
301
  rpc_request('logging/setLevel', { level: level })
246
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
302
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
303
+ MCPClient::Errors::CapabilityError
247
304
  raise
248
305
  rescue StandardError => e
249
306
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
@@ -282,11 +339,7 @@ module MCPClient
282
339
  # @return [Object] the result of the prompt (with string keys for backward compatibility)
283
340
  # @raise [MCPClient::Errors::PromptGetError] if prompt retrieval fails
284
341
  def get_prompt(prompt_name, parameters)
285
- rpc_request('prompts/get', {
286
- name: prompt_name,
287
- arguments: parameters.except(:_meta),
288
- **parameters.slice(:_meta)
289
- })
342
+ rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
290
343
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
291
344
  # Re-raise connection/transport errors directly
292
345
  raise
@@ -371,9 +424,12 @@ module MCPClient
371
424
  # @return [Boolean] true if subscription successful
372
425
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
373
426
  def subscribe_resource(uri)
427
+ ensure_connected
428
+ require_capability!('resources', 'subscribe', method: 'resources/subscribe')
374
429
  rpc_request('resources/subscribe', { uri: uri })
375
430
  true
376
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
431
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
432
+ MCPClient::Errors::CapabilityError
377
433
  raise
378
434
  rescue StandardError => e
379
435
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
@@ -384,9 +440,12 @@ module MCPClient
384
440
  # @return [Boolean] true if unsubscription successful
385
441
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
386
442
  def unsubscribe_resource(uri)
443
+ ensure_connected
444
+ require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
387
445
  rpc_request('resources/unsubscribe', { uri: uri })
388
446
  true
389
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
447
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
448
+ MCPClient::Errors::CapabilityError
390
449
  raise
391
450
  rescue StandardError => e
392
451
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
@@ -397,23 +456,20 @@ module MCPClient
397
456
  super
398
457
 
399
458
  # Add session and protocol version headers for non-initialize requests
400
- if request['method'] != 'initialize'
401
- if @session_id
402
- req.headers['Mcp-Session-Id'] = @session_id
403
- @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
404
- end
459
+ return unless request['method'] != 'initialize'
405
460
 
406
- if @protocol_version
407
- req.headers['Mcp-Protocol-Version'] = @protocol_version
408
- @logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
409
- end
461
+ if @session_id
462
+ req.headers['Mcp-Session-Id'] = @session_id
463
+ @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
410
464
  end
411
465
 
412
- # Add Last-Event-ID header for resumability (if available)
413
- return unless @last_event_id
466
+ return unless @protocol_version
414
467
 
415
- req.headers['Last-Event-ID'] = @last_event_id
416
- @logger.debug("Adding Last-Event-ID header: #{@last_event_id}")
468
+ req.headers['Mcp-Protocol-Version'] = @protocol_version
469
+ @logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
470
+
471
+ # NOTE: Last-Event-ID is deliberately NOT sent on POSTs — per SEP-1699,
472
+ # resumption is always via HTTP GET with Last-Event-ID.
417
473
  end
418
474
 
419
475
  # Override handle_successful_response to capture session ID
@@ -478,6 +534,9 @@ module MCPClient
478
534
  @events_connection = nil
479
535
  @session_id = nil
480
536
  @last_event_id = nil
537
+ @sse_retry_ms = nil
538
+ @pending_stream_responses.each_value(&:close)
539
+ @pending_stream_responses.clear
481
540
 
482
541
  # Clear cached data
483
542
  @tools = nil
@@ -486,7 +545,8 @@ module MCPClient
486
545
  @prompts_data = nil
487
546
  @resources = nil
488
547
  @resources_data = nil
489
- @buffer = ''
548
+ @buffer = +''
549
+ @buffer_scanned = 0
490
550
 
491
551
  @logger.info('Cleanup completed')
492
552
  end
@@ -528,6 +588,17 @@ module MCPClient
528
588
 
529
589
  # Default options for server initialization
530
590
  # @return [Hash] Default options
591
+ # Validate the configured decompression ceiling.
592
+ # @param value [Object] the max_decompressed_body_bytes option
593
+ # @return [Integer] the validated positive byte limit
594
+ # @raise [ArgumentError] if the value is not a positive Integer
595
+ def validate_decompression_limit(value)
596
+ return value if value.is_a?(Integer) && value.positive?
597
+
598
+ raise ArgumentError,
599
+ "max_decompressed_body_bytes must be a positive Integer, got #{value.inspect}"
600
+ end
601
+
531
602
  def default_options
532
603
  {
533
604
  endpoint: '/rpc',
@@ -538,7 +609,8 @@ module MCPClient
538
609
  name: nil,
539
610
  logger: nil,
540
611
  oauth_provider: nil,
541
- faraday_config: nil
612
+ faraday_config: nil,
613
+ max_decompressed_body_bytes: JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
542
614
  }
543
615
  end
544
616
 
@@ -688,7 +760,7 @@ module MCPClient
688
760
  break unless @mutex.synchronize { @connection_established }
689
761
 
690
762
  @logger.info('Events connection closed, reconnecting...')
691
- sleep reconnect_delay
763
+ sleep events_reconnect_delay(reconnect_delay)
692
764
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
693
765
 
694
766
  # Intentional shutdown
@@ -697,53 +769,267 @@ module MCPClient
697
769
  break unless @mutex.synchronize { @connection_established }
698
770
 
699
771
  @logger.debug('Events connection timed out after inactivity, reconnecting...')
700
- sleep reconnect_delay
772
+ sleep events_reconnect_delay(reconnect_delay)
701
773
  rescue Faraday::ConnectionFailed => e
702
774
  break unless @mutex.synchronize { @connection_established }
703
775
 
704
776
  @logger.warn("Events connection failed: #{e.message}, retrying in #{reconnect_delay}s...")
705
- sleep reconnect_delay
777
+ sleep events_reconnect_delay(reconnect_delay)
706
778
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
707
779
  rescue StandardError => e
708
780
  break unless @mutex.synchronize { @connection_established }
709
781
 
710
782
  @logger.error("Unexpected error in events connection: #{e.class} - #{e.message}")
711
783
  @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
712
- sleep reconnect_delay
784
+ sleep events_reconnect_delay(reconnect_delay)
713
785
  reconnect_delay = [reconnect_delay * 2, SSE_MAX_RECONNECT_DELAY].min
714
786
  end
715
787
  ensure
716
788
  @logger.info('Events connection thread terminated')
717
789
  end
718
790
 
791
+ # Reconnect delay for the events stream: the server's SSE retry directive
792
+ # (in ms) when present, otherwise the caller's backoff value. The
793
+ # peer-controlled directive is floored at MIN_EVENTS_RECONNECT_DELAY so a
794
+ # zero/near-zero value cannot drive a tight reconnect loop; the
795
+ # deadline-bounded resumption loop intentionally keeps honoring zero.
796
+ # @param fallback_seconds [Numeric] exponential-backoff fallback
797
+ # @return [Numeric] delay in seconds
798
+ def events_reconnect_delay(fallback_seconds)
799
+ retry_ms = @sse_retry_ms
800
+ return fallback_seconds unless retry_ms
801
+
802
+ [retry_ms / 1000.0, MIN_EVENTS_RECONNECT_DELAY].max
803
+ end
804
+
805
+ # Wait for a response replayed after the POST stream was closed before
806
+ # delivering it (SEP-1699 polling pattern). A dedicated worker issues
807
+ # HTTP GETs carrying the disconnected stream's Last-Event-ID cursor — the
808
+ # general events stream (opened without that cursor) cannot receive the
809
+ # replay.
810
+ # @param request_id [Integer, String] id of the outstanding request
811
+ # @param cursor [String] last event id received on the closed stream
812
+ # @param retry_ms [Integer, nil] retry directive received on the closed
813
+ # stream itself (not the shared events-stream directive)
814
+ # @return [Hash, nil] the replayed JSON-RPC response, or nil on timeout
815
+ def resume_response_via_get(request_id, cursor, retry_ms = nil)
816
+ queue = Thread::Queue.new
817
+ @mutex.synchronize { @pending_stream_responses[request_id] = queue }
818
+
819
+ resume_thread = Thread.new { run_resumption_loop(request_id, cursor, retry_ms) }
820
+ begin
821
+ queue.pop(timeout: @read_timeout)
822
+ ensure
823
+ @mutex.synchronize { @pending_stream_responses.delete(request_id) }
824
+ resume_thread.kill
825
+ resume_thread.join(1)
826
+ end
827
+ end
828
+
829
+ # Reconnecting worker for SEP-1699 resumption. The server MAY close a
830
+ # resumed stream again before returning the response (polling pattern),
831
+ # so each iteration issues a GET with the CURRENT cursor until the
832
+ # overall read timeout elapses or the waiter has been served. `id:`
833
+ # fields received on the resumed stream advance the cursor and `retry:`
834
+ # fields update the delay honored before the next reconnect (zero is a
835
+ # valid immediate-reconnect directive).
836
+ # @param request_id [Integer, String] id of the outstanding request
837
+ # @param cursor [String] Last-Event-ID cursor from the closed stream
838
+ # @param retry_ms [Integer, nil] retry directive from the closed stream
839
+ # @return [void]
840
+ def run_resumption_loop(request_id, cursor, retry_ms)
841
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
842
+ state = { cursor: cursor, retry_ms: retry_ms }
843
+ # SEP-1699: the client MUST respect the server's retry directive before
844
+ # attempting to reconnect. With no directive the FIRST GET goes out
845
+ # immediately, as before this change: delaying it adds latency to every
846
+ # resumption and, on a short read_timeout, can burn the whole budget
847
+ # before any I/O happens.
848
+ delay = retry_ms ? resumption_delay(retry_ms) : 0
849
+
850
+ loop do
851
+ sleep(delay) if delay.positive?
852
+ issue_resumption_get(state)
853
+ break unless @mutex.synchronize { @pending_stream_responses.key?(request_id) }
854
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
855
+
856
+ delay = resumption_delay(state[:retry_ms])
857
+ end
858
+ end
859
+
860
+ # Delay before the next resumption GET: the peer's retry directive when
861
+ # present, floored so "retry: 0" cannot turn the deadline window into a
862
+ # back-to-back request loop, otherwise the standard reconnect delay.
863
+ # @param retry_ms [Integer, nil] retry directive in milliseconds
864
+ # @return [Numeric] delay in seconds
865
+ def resumption_delay(retry_ms)
866
+ return SSE_RECONNECT_DELAY unless retry_ms
867
+
868
+ [retry_ms / 1000.0, MIN_RESUMPTION_RECONNECT_DELAY].max
869
+ end
870
+
871
+ # One GET with the current cursor; complete SSE events are dispatched
872
+ # through the standard server-message path, which routes replayed
873
+ # responses to their registered waiters. `id:` and `retry:` fields
874
+ # received on this stream update the resumption state live.
875
+ # @param state [Hash] mutable resumption state (:cursor, :retry_ms)
876
+ # @return [void]
877
+ def issue_resumption_get(state)
878
+ conn = Faraday.new(url: @base_url) do |f|
879
+ f.options.open_timeout = 10
880
+ f.options.timeout = @read_timeout
881
+ f.adapter :net_http
882
+ end
883
+
884
+ buffer = +''
885
+ conn.get(@endpoint) do |req|
886
+ apply_events_headers(req)
887
+ req.headers['Last-Event-ID'] = state[:cursor]
888
+ req.options.on_data = proc do |chunk, _bytes|
889
+ buffer << chunk
890
+ process_resumption_buffer(buffer, state)
891
+ # A peer replaying delimiter-free data must not grow this buffer
892
+ # without bound: abort the GET (rescued below); the deadline-bounded
893
+ # resumption loop decides whether to retry with a fresh buffer.
894
+ enforce_sse_buffer_cap!(buffer)
895
+ end
896
+ end
897
+ rescue StandardError => e
898
+ @logger.debug("Resumption GET failed: #{e.message}")
899
+ end
900
+
901
+ # Extract complete SSE events (terminated by a blank line, LF or CRLF)
902
+ # from the resumption buffer and handle each of them.
903
+ # @param buffer [String] mutable stream buffer
904
+ # @param state [Hash] mutable resumption state (:cursor, :retry_ms)
905
+ # @return [void]
906
+ def process_resumption_buffer(buffer, state)
907
+ # Same incremental scan as the events stream: without a cursor every
908
+ # chunk re-matched the whole accumulated buffer from byte zero.
909
+ scan_from = [state[:scanned].to_i - 3, 0].max
910
+ while (separator = buffer.match(/\r\n\r\n|\n\n/, scan_from))
911
+ event_text = buffer.slice!(0, separator.end(0))
912
+ handle_resumption_event(event_text, state)
913
+ scan_from = 0
914
+ state[:scanned] = 0
915
+ end
916
+ state[:scanned] = buffer.length
917
+ end
918
+
919
+ # Parse one SSE event received on a resumption stream: track `id:` lines
920
+ # (advancing the cursor used by the next reconnect) and `retry:` lines
921
+ # (updating the reconnect delay; zero is valid), then dispatch any data.
922
+ # @param event_text [String] one complete SSE event, separator included
923
+ # @param state [Hash] mutable resumption state (:cursor, :retry_ms)
924
+ # @return [void]
925
+ def handle_resumption_event(event_text, state)
926
+ data_lines = []
927
+ event_text.each_line do |raw_line|
928
+ line = raw_line.chomp
929
+ if line.start_with?('data:')
930
+ data_lines << line.sub(/\Adata:\s*/, '')
931
+ elsif line.start_with?('id:')
932
+ id = line.sub(/\Aid:\s*/, '').strip
933
+ # Same validation as the events stream: this cursor is echoed in
934
+ # the Last-Event-ID header of the next resumption GET.
935
+ state[:cursor] = id if retainable_event_id?(id)
936
+ elsif line.start_with?('retry:')
937
+ raw = line.sub(/\Aretry:\s*/, '').strip
938
+ state[:retry_ms] = raw.to_i if raw.match?(/\A\d+\z/)
939
+ end
940
+ end
941
+ handle_server_message(data_lines.join("\n")) unless data_lines.empty?
942
+ end
943
+
944
+ # Deliver a response replayed on the events stream to a waiting request.
945
+ # The pending entry is removed on delivery so resumption workers can see
946
+ # that their waiter has been served.
947
+ # @param message [Hash] a JSON-RPC response message
948
+ # @return [void]
949
+ def deliver_stream_response(message)
950
+ queue = @mutex.synchronize do
951
+ key = [message['id'], message['id'].to_s].find { |k| @pending_stream_responses.key?(k) }
952
+ @pending_stream_responses.delete(key) unless key.nil?
953
+ end
954
+ queue << message if queue
955
+ end
956
+
719
957
  # Apply headers for events connection
720
958
  # @param req [Faraday::Request] HTTP request
721
959
  def apply_events_headers(req)
722
960
  @headers.each { |k, v| req.headers[k] = v }
723
961
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
724
962
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
963
+ # MCP: authorization MUST be included in every HTTP request
964
+ @oauth_provider&.apply_authorization(req)
965
+ # SEP-1699: resumption is via GET with the Last-Event-ID cursor, so the
966
+ # server can replay messages missed since the last received event.
967
+ last_event_id = @mutex.synchronize { @last_event_id }
968
+ req.headers['Last-Event-ID'] = last_event_id if last_event_id
725
969
  end
726
970
 
727
971
  # Process event chunks from the server
728
972
  # Buffers partial chunks and processes complete SSE events
729
973
  # @param chunk [String] the chunk to process
730
974
  def process_event_chunk(chunk)
731
- @logger.debug("Processing event chunk: #{chunk.inspect}") if @logger.level <= Logger::DEBUG
975
+ # Size only: the chunk is raw wire data carrying sampling prompts,
976
+ # elicitation content and tool results.
977
+ @logger.debug("Processing event chunk (#{describe_body_size(chunk)})") if @logger.level <= Logger::DEBUG
732
978
 
733
979
  @mutex.synchronize do
734
- @buffer += chunk
735
-
736
- # Extract complete events (SSE format: events end with double newline)
737
- while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
980
+ # Append in place and scan only the newly arrived bytes. `+= chunk`
981
+ # reallocated and copied the whole buffer per callback, and each
982
+ # search restarted at index 0, so an unterminated event delivered in
983
+ # N chunks cost O(N^2) work: capped memory, uncapped CPU.
984
+ @buffer << chunk
985
+
986
+ scan_from = [@buffer_scanned - 3, 0].max
987
+ while (event_end = next_buffer_event_end(scan_from))
738
988
  event_data = extract_event(event_end)
739
989
  parse_and_handle_event(event_data)
990
+ scan_from = 0
991
+ @buffer_scanned = 0
992
+ end
993
+ @buffer_scanned = @buffer.length
994
+
995
+ # Everything left is a partial event awaiting its terminator; cap how
996
+ # much a peer may accumulate. The raise propagates (unlike processing
997
+ # errors below) so the events loop drops this connection and its
998
+ # backoff takes over — memory stays bounded either way.
999
+ begin
1000
+ enforce_sse_buffer_cap!(@buffer)
1001
+ rescue MCPClient::Errors::ConnectionError
1002
+ @buffer = +''
1003
+ @buffer_scanned = 0
1004
+ raise
740
1005
  end
741
1006
  end
1007
+ rescue MCPClient::Errors::ConnectionError
1008
+ raise
742
1009
  rescue StandardError => e
743
1010
  @logger.error("Error processing event chunk: #{e.message}")
744
1011
  @logger.debug(e.backtrace.join("\n")) if @logger.level <= Logger::DEBUG
745
1012
  end
746
1013
 
1014
+ # Index of the earliest event terminator at or after an offset.
1015
+ # @param offset [Integer] character offset to start searching from
1016
+ # @return [Integer, nil] index of the terminator, or nil if none yet
1017
+ def next_buffer_event_end(offset)
1018
+ lf = @buffer.index("\n\n", offset)
1019
+ crlf = @buffer.index("\r\n\r\n", offset)
1020
+ [lf, crlf].compact.min
1021
+ end
1022
+
1023
+ # @param buffer [String] a partial-event SSE buffer
1024
+ # @raise [MCPClient::Errors::ConnectionError] when the buffer exceeds MAX_SSE_BUFFER_BYTES
1025
+ def enforce_sse_buffer_cap!(buffer)
1026
+ return if buffer.bytesize <= MAX_SSE_BUFFER_BYTES
1027
+
1028
+ raise MCPClient::Errors::ConnectionError,
1029
+ "SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
1030
+ 'without a terminator'
1031
+ end
1032
+
747
1033
  # Extract a single event from the buffer
748
1034
  # @param event_end [Integer] the position where the event ends
749
1035
  # @return [String] the extracted event data
@@ -775,13 +1061,18 @@ module MCPClient
775
1061
  # SSE allows multiple data lines that should be joined with newlines
776
1062
  data_lines << line[5..].strip
777
1063
  elsif line.start_with?('id:')
778
- # Track event ID for resumability (MCP future enhancement)
1064
+ # Track event ID for resumability (MCP future enhancement). The id
1065
+ # is peer-controlled and gets echoed in the Last-Event-ID header, so
1066
+ # only a bounded, header-safe value is retained; the previous valid
1067
+ # cursor is kept when one is rejected.
779
1068
  event[:id] = line[3..].strip
780
- @last_event_id = event[:id]
1069
+ @last_event_id = event[:id] if retainable_event_id?(event[:id])
781
1070
  elsif line.start_with?('retry:')
782
- # Server can suggest reconnection delay (in milliseconds)
783
- retry_ms = line[6..].strip.to_i
784
- @logger.debug("Server suggested retry delay: #{retry_ms}ms") if @logger.level <= Logger::DEBUG
1071
+ # SEP-1699: the client MUST respect the server's retry directive
1072
+ # (milliseconds) when reconnecting; zero is a valid directive.
1073
+ raw = line[6..].strip
1074
+ @sse_retry_ms = raw.to_i if raw.match?(/\A\d+\z/)
1075
+ @logger.debug("Server suggested retry delay: #{raw}ms") if @logger.level <= Logger::DEBUG
785
1076
  end
786
1077
  end
787
1078
 
@@ -799,20 +1090,39 @@ module MCPClient
799
1090
 
800
1091
  begin
801
1092
  message = JSON.parse(data)
802
-
803
- # Handle ping requests from server (keepalive mechanism)
804
- if message['method'] == 'ping' && message.key?('id')
805
- handle_ping_request(message['id'])
806
- elsif message['method'] && message.key?('id')
807
- # Handle server-to-client requests (MCP 2025-06-18)
808
- handle_server_request(message)
809
- elsif message['method'] && !message.key?('id')
810
- # Handle server notifications (messages without id)
811
- @notification_callback&.call(message['method'], message['params'])
1093
+ unless message.is_a?(Hash)
1094
+ # A JSON-parseable scalar/array is not a JSON-RPC message; dispatch
1095
+ # would raise on it. Log the type, never the value.
1096
+ @logger.warn("Skipping non-object JSON-RPC message on the events stream (#{message.class})")
1097
+ return
812
1098
  end
1099
+
1100
+ dispatch_server_message(message)
813
1101
  rescue JSON::ParserError => e
814
- @logger.error("Invalid JSON in server message: #{e.message}")
815
- @logger.debug("Raw data: #{data.inspect}") if @logger.level <= Logger::DEBUG
1102
+ # The parser message names the failure position, not the payload; the
1103
+ # payload itself stays out of the log.
1104
+ @logger.error("Invalid JSON in server message: #{describe_parse_error(e, data)}")
1105
+ end
1106
+ end
1107
+
1108
+ # Dispatch a parsed server message (request, ping, or notification).
1109
+ # Used for messages arriving on the GET events stream and for messages
1110
+ # interleaved on a POST SSE response stream.
1111
+ # @param message [Hash] the parsed JSON-RPC message
1112
+ def dispatch_server_message(message)
1113
+ # Handle ping requests from server (keepalive mechanism)
1114
+ if message['method'] == 'ping' && message.key?('id')
1115
+ handle_ping_request(message['id'])
1116
+ elsif message['method'] && message.key?('id')
1117
+ # Handle server-to-client requests (MCP 2025-06-18)
1118
+ handle_server_request(message)
1119
+ elsif message['method'] && !message.key?('id')
1120
+ # Handle server notifications (messages without id)
1121
+ @notification_callback&.call(message['method'], message['params'])
1122
+ elsif message.key?('id')
1123
+ # A response replayed on the events stream after its POST stream was
1124
+ # closed before delivery (SEP-1699 resumption)
1125
+ deliver_stream_response(message)
816
1126
  end
817
1127
  end
818
1128
 
@@ -826,24 +1136,9 @@ module MCPClient
826
1136
  result: {}
827
1137
  }
828
1138
 
829
- # Send pong response in a separate thread to avoid blocking event processing
830
- Thread.new do
831
- conn = http_connection
832
- response = conn.post(@endpoint) do |req|
833
- @headers.each { |k, v| req.headers[k] = v }
834
- req.headers['Mcp-Session-Id'] = @session_id if @session_id
835
- req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
836
- req.body = pong_response.to_json
837
- end
838
-
839
- if response.success?
840
- @logger.debug("Sent pong response for ping ID: #{ping_id}") if @logger.level <= Logger::DEBUG
841
- else
842
- @logger.warn("Failed to send pong response: HTTP #{response.status}")
843
- end
844
- rescue StandardError => e
845
- @logger.error("Failed to send pong response: #{e.message}")
846
- end
1139
+ # Pongs go through the same bounded response path as every other
1140
+ # server-initiated reply, so a ping flood cannot fan out threads.
1141
+ post_jsonrpc_response(pong_response)
847
1142
  end
848
1143
 
849
1144
  # Handle incoming JSON-RPC request from server (MCP 2025-06-18)
@@ -868,30 +1163,31 @@ module MCPClient
868
1163
  send_error_response(request_id, -32_601, "Method not found: #{method}")
869
1164
  end
870
1165
  rescue StandardError => e
1166
+ # The exception message is host-internal (file paths, connection
1167
+ # strings, library internals): log it locally, but answer the peer with
1168
+ # a constant message so failures cannot be used to probe the host.
871
1169
  @logger.error("Error handling server request: #{e.message}")
872
- send_error_response(request_id, -32_603, "Internal error: #{e.message}")
1170
+ send_error_response(request_id, -32_603, 'Internal error')
873
1171
  end
874
1172
 
875
- # Handle elicitation/create request from server (MCP 2025-06-18)
876
- # @param request_id [String, Integer] the JSON-RPC request ID (used as elicitationId)
1173
+ # Handle elicitation/create request from server (MCP 2025-11-25)
1174
+ # @param request_id [String, Integer] the JSON-RPC request ID
877
1175
  # @param params [Hash] the elicitation parameters
878
1176
  # @return [void]
879
1177
  def handle_elicitation_create(request_id, params)
880
- # The request_id is the elicitationId per MCP spec
881
- elicitation_id = request_id
882
-
883
- # If no callback is registered, decline the request
1178
+ # Without a callback there is no user to interact with: answer with a
1179
+ # JSON-RPC error rather than fabricating a user "decline".
884
1180
  unless @elicitation_request_callback
885
- @logger.warn('Received elicitation request but no callback registered, declining')
886
- send_elicitation_response(elicitation_id, { 'action' => 'decline' })
1181
+ @logger.warn('Received elicitation request but no callback registered')
1182
+ send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
887
1183
  return
888
1184
  end
889
1185
 
890
1186
  # Call the registered callback
891
1187
  result = @elicitation_request_callback.call(request_id, params)
892
1188
 
893
- # Send the response back to the server
894
- send_elicitation_response(elicitation_id, result)
1189
+ # Send the response back to the server (echoing related-task _meta)
1190
+ send_elicitation_response(request_id, merge_related_task_meta(result, params))
895
1191
  end
896
1192
 
897
1193
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -909,8 +1205,8 @@ module MCPClient
909
1205
  # Call the registered callback
910
1206
  result = @roots_list_request_callback.call(request_id, params)
911
1207
 
912
- # Send the response back to the server
913
- send_roots_list_response(request_id, result)
1208
+ # Send the response back to the server (echoing related-task _meta)
1209
+ send_roots_list_response(request_id, merge_related_task_meta(result, params))
914
1210
  end
915
1211
 
916
1212
  # Handle sampling/createMessage request from server (MCP 2025-11-25)
@@ -928,8 +1224,8 @@ module MCPClient
928
1224
  # Call the registered callback
929
1225
  result = @sampling_request_callback.call(request_id, params)
930
1226
 
931
- # Send the response back to the server
932
- send_sampling_response(request_id, result)
1227
+ # Send the response back to the server (echoing related-task _meta)
1228
+ send_sampling_response(request_id, merge_related_task_meta(result, params))
933
1229
  end
934
1230
 
935
1231
  # Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
@@ -972,29 +1268,29 @@ module MCPClient
972
1268
  @logger.error("Error sending sampling response: #{e.message}")
973
1269
  end
974
1270
 
975
- # Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
976
- # For streamable HTTP, this is sent as a JSON-RPC request (not response)
977
- # because HTTP is unidirectional.
978
- # @param elicitation_id [String] the elicitation ID from the server
1271
+ # Send elicitation response back to server via HTTP POST (MCP 2025-11-25)
1272
+ # The reply to a server's elicitation/create request is a standard
1273
+ # JSON-RPC response echoing the request id, POSTed like every other
1274
+ # response on this transport.
1275
+ # @param request_id [String, Integer] the JSON-RPC request ID
979
1276
  # @param result [Hash] the elicitation result (action and optional content)
980
1277
  # @return [void]
981
- def send_elicitation_response(elicitation_id, result)
982
- params = {
983
- 'elicitationId' => elicitation_id,
984
- 'action' => result['action']
985
- }
986
-
987
- # Only include content if present (typically for 'accept' action)
988
- params['content'] = result['content'] if result['content']
1278
+ def send_elicitation_response(request_id, result)
1279
+ # Error-shaped results become JSON-RPC error responses (e.g. -32602 for
1280
+ # an undeclared elicitation mode), mirroring the sampling error path.
1281
+ if result.is_a?(Hash) && result['error']
1282
+ send_error_response(request_id, result['error']['code'] || -32_603,
1283
+ result['error']['message'] || 'Elicitation error')
1284
+ return
1285
+ end
989
1286
 
990
- request = {
1287
+ response = {
991
1288
  'jsonrpc' => '2.0',
992
- 'method' => 'elicitation/response',
993
- 'params' => params
1289
+ 'id' => request_id,
1290
+ 'result' => result
994
1291
  }
995
1292
 
996
- # Send as a JSON-RPC request via HTTP POST
997
- post_jsonrpc_response(request)
1293
+ post_jsonrpc_response(response)
998
1294
  rescue StandardError => e
999
1295
  @logger.error("Error sending elicitation response: #{e.message}")
1000
1296
  end
@@ -1025,7 +1321,31 @@ module MCPClient
1025
1321
  # @return [void]
1026
1322
  # @private
1027
1323
  def post_jsonrpc_response(response)
1028
- # Send response in a separate thread to avoid blocking event processing
1324
+ # Send the response in a separate thread to avoid blocking event
1325
+ # processing, but never beyond the concurrency budget: server requests
1326
+ # arrive at the peer's rate, and each unanswered POST would otherwise
1327
+ # pin one thread and one connection.
1328
+ unless acquire_response_post_slot
1329
+ log_response_post_saturation
1330
+ return
1331
+ end
1332
+
1333
+ begin
1334
+ start_response_post_thread(response)
1335
+ rescue ThreadError => e
1336
+ # Thread.new failed, so the ensure inside the block never runs and the
1337
+ # reservation would leak. Eight such failures would silently mute this
1338
+ # instance's replies for the rest of its life, including after
1339
+ # reconnect (cleanup does not reset the counter).
1340
+ release_response_post_slot
1341
+ @logger.error("Failed to start response POST thread: #{e.message}")
1342
+ end
1343
+ end
1344
+
1345
+ # Spawn the worker that POSTs one server-initiated response.
1346
+ # @param response [Hash] the JSON-RPC response
1347
+ # @return [Thread]
1348
+ def start_response_post_thread(response)
1029
1349
  Thread.new do
1030
1350
  conn = http_connection
1031
1351
  json_body = JSON.generate(response)
@@ -1034,17 +1354,61 @@ module MCPClient
1034
1354
  @headers.each { |k, v| req.headers[k] = v }
1035
1355
  req.headers['Mcp-Session-Id'] = @session_id if @session_id
1036
1356
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
1357
+ # MCP: authorization MUST be included in every HTTP request
1358
+ @oauth_provider&.apply_authorization(req)
1037
1359
  req.body = json_body
1038
1360
  end
1039
1361
 
1040
1362
  if resp.success?
1041
- @logger.debug("Sent JSON-RPC response: #{json_body}")
1363
+ @logger.debug("Sent JSON-RPC response: #{describe_jsonrpc_message(response)}")
1042
1364
  else
1043
1365
  @logger.warn("Failed to send JSON-RPC response: HTTP #{resp.status}")
1044
1366
  end
1045
1367
  rescue StandardError => e
1046
1368
  @logger.error("Failed to send JSON-RPC response: #{e.message}")
1369
+ ensure
1370
+ release_response_post_slot
1047
1371
  end
1048
1372
  end
1373
+
1374
+ # Warn that the response-POST budget is saturated, at most once per
1375
+ # SATURATION_LOG_INTERVAL.
1376
+ #
1377
+ # The peer controls how often this path is reached, so logging every
1378
+ # rejection (at WARN, which the default logger emits) would just trade a
1379
+ # thread-exhaustion vector for a log-volume one. The peer-supplied request
1380
+ # id is deliberately omitted for the same reason.
1381
+ # @return [void]
1382
+ def log_response_post_saturation
1383
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1384
+ dropped = @mutex.synchronize do
1385
+ @dropped_response_posts += 1
1386
+ next nil if @last_saturation_log_at && now - @last_saturation_log_at < SATURATION_LOG_INTERVAL
1387
+
1388
+ @last_saturation_log_at = now
1389
+ @dropped_response_posts
1390
+ end
1391
+ return unless dropped
1392
+
1393
+ @logger.warn("Dropping server-initiated responses: #{MAX_CONCURRENT_RESPONSE_POSTS} " \
1394
+ "response POSTs already in flight (#{dropped} dropped so far)")
1395
+ end
1396
+
1397
+ # Reserve one slot of the response-POST concurrency budget.
1398
+ # @return [Boolean] whether a slot was available
1399
+ def acquire_response_post_slot
1400
+ @mutex.synchronize do
1401
+ return false if @response_post_count >= MAX_CONCURRENT_RESPONSE_POSTS
1402
+
1403
+ @response_post_count += 1
1404
+ true
1405
+ end
1406
+ end
1407
+
1408
+ # Release a slot reserved by acquire_response_post_slot.
1409
+ # @return [void]
1410
+ def release_response_post_slot
1411
+ @mutex.synchronize { @response_post_count -= 1 }
1412
+ end
1049
1413
  end
1050
1414
  end