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
@@ -40,6 +40,13 @@ module MCPClient
40
40
  MAX_RECONNECT_DELAY = 30
41
41
  JITTER_FACTOR = 0.25
42
42
 
43
+ # Maximum bytes the SSE parse buffer may hold while waiting for an event
44
+ # terminator. The stream is peer-controlled: without a cap, a hostile
45
+ # server could withhold the blank-line delimiter forever and grow the
46
+ # buffer until the host runs out of memory. Generous enough for any
47
+ # legitimate JSON-RPC response event.
48
+ MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
49
+
43
50
  # @!attribute [r] base_url
44
51
  # @return [String] The base URL of the MCP server
45
52
  # @!attribute [r] tools
@@ -91,13 +98,25 @@ module MCPClient
91
98
  @tools_data = nil
92
99
  @request_id = 0
93
100
  @sse_results = {}
101
+ # Ids of requests a caller is actively waiting on. Only responses for
102
+ # these ids are stored in @sse_results — everything else on the peer
103
+ # controlled stream is unsolicited and discarded.
104
+ @pending_request_ids = Set.new
94
105
  @mutex = Monitor.new
95
- @buffer = ''
106
+ @buffer = +''
107
+ # How much of @buffer has already been searched for an event terminator
108
+ @buffer_scanned = 0
96
109
  @sse_connected = false
97
110
  @connection_established = false
98
111
  @connection_cv = @mutex.new_cond
99
112
  @initialized = false
113
+ # Negotiated protocol version captured from the initialize result
114
+ # (sent as the MCP-Protocol-Version header on post-initialize requests)
115
+ @protocol_version = nil
100
116
  @auth_error = nil
117
+ # Non-auth connection failure cause (e.g. invalid endpoint event URI)
118
+ # recorded by the SSE worker for wait_for_connection to surface
119
+ @connection_error = nil
101
120
  # Whether to use SSE transport; may disable if handshake fails
102
121
  @use_sse = true
103
122
 
@@ -157,10 +176,7 @@ module MCPClient
157
176
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
158
177
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
159
178
  def get_prompt(prompt_name, parameters)
160
- rpc_request('prompts/get', {
161
- name: prompt_name,
162
- arguments: parameters
163
- })
179
+ rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
164
180
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
165
181
  # Re-raise connection/transport errors directly to match test expectations
166
182
  raise
@@ -254,9 +270,11 @@ module MCPClient
254
270
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
255
271
  def subscribe_resource(uri)
256
272
  ensure_initialized
273
+ require_capability!('resources', 'subscribe', method: 'resources/subscribe')
257
274
  rpc_request('resources/subscribe', { uri: uri })
258
275
  true
259
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
276
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
277
+ MCPClient::Errors::CapabilityError
260
278
  raise
261
279
  rescue StandardError => e
262
280
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
@@ -269,9 +287,11 @@ module MCPClient
269
287
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
270
288
  def unsubscribe_resource(uri)
271
289
  ensure_initialized
290
+ require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
272
291
  rpc_request('resources/unsubscribe', { uri: uri })
273
292
  true
274
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
293
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
294
+ MCPClient::Errors::CapabilityError
275
295
  raise
276
296
  rescue StandardError => e
277
297
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
@@ -315,10 +335,7 @@ module MCPClient
315
335
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
316
336
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
317
337
  def call_tool(tool_name, parameters)
318
- rpc_request('tools/call', {
319
- name: tool_name,
320
- arguments: parameters
321
- })
338
+ rpc_request('tools/call', build_named_request_params(tool_name, parameters))
322
339
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
323
340
  # Re-raise connection/transport errors directly to match test expectations
324
341
  raise
@@ -334,11 +351,14 @@ module MCPClient
334
351
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
335
352
  # @raise [MCPClient::Errors::ServerError] if server returns an error
336
353
  def complete(ref:, argument:, context: nil)
354
+ ensure_initialized
355
+ require_capability!('completions', method: 'completion/complete')
337
356
  params = { ref: ref, argument: argument }
338
357
  params[:context] = context if context
339
358
  result = rpc_request('completion/complete', params)
340
359
  result['completion'] || { 'values' => [] }
341
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
360
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
361
+ MCPClient::Errors::CapabilityError
342
362
  raise
343
363
  rescue StandardError => e
344
364
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
@@ -350,8 +370,11 @@ module MCPClient
350
370
  # @return [Hash] empty result on success
351
371
  # @raise [MCPClient::Errors::ServerError] if server returns an error
352
372
  def log_level=(level)
373
+ ensure_initialized
374
+ require_capability!('logging', method: 'logging/setLevel')
353
375
  rpc_request('logging/setLevel', { level: level })
354
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
376
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
377
+ MCPClient::Errors::CapabilityError
355
378
  raise
356
379
  rescue StandardError => e
357
380
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
@@ -369,6 +392,8 @@ module MCPClient
369
392
  begin
370
393
  # Don't reset auth error if it's pre-existing
371
394
  @mutex.synchronize { @auth_error = nil } unless pre_existing_auth_error
395
+ # Clear any stale connection failure cause from a previous attempt
396
+ @mutex.synchronize { @connection_error = nil }
372
397
 
373
398
  start_sse_thread
374
399
  effective_timeout = [@read_timeout || 30, 30].min
@@ -404,10 +429,25 @@ module MCPClient
404
429
  @connection_established = false
405
430
  @sse_connected = false
406
431
  @initialized = false # Reset initialization state for reconnection
432
+ # A fresh session negotiates its own protocol version; keeping the old
433
+ # one would leak the previous session's version into the next
434
+ # initialize POST's MCP-Protocol-Version header.
435
+ @protocol_version = nil
407
436
 
408
437
  # Reset the SSE parse buffer so a reconnect never inherits a leftover
409
438
  # partial event from the previous connection.
410
- @buffer = ''
439
+ @buffer = +''
440
+ @buffer_scanned = 0
441
+
442
+ # Drop results nobody is waiting for, so peer-supplied state cannot
443
+ # accumulate across reconnects. Results for still-pending requests are
444
+ # KEPT: a response can arrive while its POST is still returning, and
445
+ # the waiter (which reconnects through ensure_sse_connection_active)
446
+ # is about to consume it. Discarding those reported a timeout for a
447
+ # tool call the server had already executed — inviting a duplicate
448
+ # manual retry. unregister_pending_request clears each entry when its
449
+ # request finishes, so nothing lingers.
450
+ @sse_results.select! { |id, _| @pending_request_ids.include?(id) }
411
451
 
412
452
  # Log cleanup for debugging
413
453
  @logger.debug('Cleaning up SSE connection')
@@ -503,8 +543,11 @@ module MCPClient
503
543
  send_error_response(request_id, -32_601, "Method not found: #{method}")
504
544
  end
505
545
  rescue StandardError => e
546
+ # The exception message is host-internal (file paths, connection
547
+ # strings, library internals): log it locally, but answer the peer with
548
+ # a constant message so failures cannot be used to probe the host.
506
549
  @logger.error("Error handling server request: #{e.message}")
507
- send_error_response(request_id, -32_603, "Internal error: #{e.message}")
550
+ send_error_response(request_id, -32_603, 'Internal error')
508
551
  end
509
552
 
510
553
  # Handle a server-initiated ping request (MCP ping utility)
@@ -527,18 +570,19 @@ module MCPClient
527
570
  # @param params [Hash] the elicitation parameters
528
571
  # @return [void]
529
572
  def handle_elicitation_create(request_id, params)
530
- # If no callback is registered, decline the request
573
+ # Without a callback there is no user to interact with: answer with a
574
+ # JSON-RPC error rather than fabricating a user "decline".
531
575
  unless @elicitation_request_callback
532
- @logger.warn('Received elicitation request but no callback registered, declining')
533
- send_elicitation_response(request_id, { 'action' => 'decline' })
576
+ @logger.warn('Received elicitation request but no callback registered')
577
+ send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
534
578
  return
535
579
  end
536
580
 
537
581
  # Call the registered callback
538
582
  result = @elicitation_request_callback.call(request_id, params)
539
583
 
540
- # Send the response back to the server
541
- send_elicitation_response(request_id, result)
584
+ # Send the response back to the server (echoing related-task _meta)
585
+ send_elicitation_response(request_id, merge_related_task_meta(result, params))
542
586
  end
543
587
 
544
588
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -556,8 +600,8 @@ module MCPClient
556
600
  # Call the registered callback
557
601
  result = @roots_list_request_callback.call(request_id, params)
558
602
 
559
- # Send the response back to the server
560
- send_roots_list_response(request_id, result)
603
+ # Send the response back to the server (echoing related-task _meta)
604
+ send_roots_list_response(request_id, merge_related_task_meta(result, params))
561
605
  end
562
606
 
563
607
  # Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
@@ -594,8 +638,8 @@ module MCPClient
594
638
  # Call the registered callback
595
639
  result = @sampling_request_callback.call(request_id, params)
596
640
 
597
- # Send the response back to the server
598
- send_sampling_response(request_id, result)
641
+ # Send the response back to the server (echoing related-task _meta)
642
+ send_sampling_response(request_id, merge_related_task_meta(result, params))
599
643
  end
600
644
 
601
645
  # Send sampling response back to server via HTTP POST (MCP 2025-11-25)
@@ -628,6 +672,14 @@ module MCPClient
628
672
  # @param result [Hash] the elicitation result (action and optional content)
629
673
  # @return [void]
630
674
  def send_elicitation_response(request_id, result)
675
+ # Error-shaped results become JSON-RPC error responses (e.g. -32602 for
676
+ # an undeclared elicitation mode), mirroring the sampling error path.
677
+ if result.is_a?(Hash) && result['error']
678
+ send_error_response(request_id, result['error']['code'] || -32_603,
679
+ result['error']['message'] || 'Elicitation error')
680
+ return
681
+ end
682
+
631
683
  ensure_initialized
632
684
 
633
685
  response = {
@@ -685,11 +737,14 @@ module MCPClient
685
737
  @rpc_conn.post do |req|
686
738
  req.url @rpc_endpoint
687
739
  req.headers['Content-Type'] = 'application/json'
740
+ # MCP lifecycle "Version Negotiation": include the MCP-Protocol-Version
741
+ # header on all HTTP requests after the initialize handshake.
742
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
688
743
  @headers.each { |k, v| req.headers[k] = v }
689
744
  req.body = json_body
690
745
  end
691
746
 
692
- @logger.debug("Sent response via HTTP POST: #{json_body}")
747
+ @logger.debug("Sent response via HTTP POST: #{describe_jsonrpc_message(response)}")
693
748
  rescue StandardError => e
694
749
  @logger.error("Failed to send response via HTTP POST: #{e.message}")
695
750
  end
@@ -811,7 +866,9 @@ module MCPClient
811
866
  # Process an SSE chunk from the server
812
867
  # @param chunk [String] the chunk to process
813
868
  def process_sse_chunk(chunk)
814
- @logger.debug("Processing SSE chunk: #{chunk.inspect}")
869
+ # Size only: the chunk is raw wire data carrying sampling prompts,
870
+ # elicitation content and tool results.
871
+ @logger.debug("Processing SSE chunk (#{describe_body_size(chunk)})")
815
872
 
816
873
  # Only record activity for real events
817
874
  record_activity if chunk.include?('event:')
@@ -869,21 +926,61 @@ module MCPClient
869
926
  # @return [Array<String>, nil] array of complete events or nil if none
870
927
  # @private
871
928
  def extract_complete_events(chunk)
872
- event_buffers = nil
929
+ event_buffers = []
873
930
  @mutex.synchronize do
874
- @buffer += chunk
875
-
876
- # Extract all complete events from the buffer
877
- # Handle both Unix (\n\n) and Windows (\r\n\r\n) line endings
878
- event_buffers = []
879
- while (event_end = @buffer.index("\n\n") || @buffer.index("\r\n\r\n"))
880
- event_data = extract_single_event(event_end)
881
- event_buffers << event_data
931
+ # Append in place. `@buffer += chunk` allocates and copies the whole
932
+ # buffer on every callback, so an unterminated event delivered in N
933
+ # chunks costs O(N^2) copying memory stays capped but a peer can
934
+ # still burn CPU and thrash the allocator on the way there.
935
+ @buffer << chunk
936
+
937
+ # Rescan only the newly arrived bytes, backing up by the longest
938
+ # delimiter minus one so one split across two chunks is still found.
939
+ scan_from = [@buffer_scanned - 3, 0].max
940
+ while (event_end = next_event_end(scan_from))
941
+ event_buffers << extract_single_event(event_end)
942
+ # The buffer shifted; what remains is short (one event at most).
943
+ scan_from = 0
944
+ @buffer_scanned = 0
882
945
  end
946
+ @buffer_scanned = @buffer.length
947
+
948
+ # Whatever is left is a partial event still awaiting its terminator.
949
+ # The cap is applied here rather than before appending so a single
950
+ # oversized chunk that DOES contain complete events is still parsed.
951
+ fail_oversized_sse_buffer! if @buffer.bytesize > MAX_SSE_BUFFER_BYTES
883
952
  end
884
953
  event_buffers
885
954
  end
886
955
 
956
+ # Index of the earliest event terminator at or after an offset.
957
+ # @param offset [Integer] character offset to start searching from
958
+ # @return [Integer, nil] index of the terminator, or nil if none yet
959
+ def next_event_end(offset)
960
+ lf = @buffer.index("\n\n", offset)
961
+ crlf = @buffer.index("\r\n\r\n", offset)
962
+ [lf, crlf].compact.min
963
+ end
964
+
965
+ # Drop an oversized partial event and fail the connection.
966
+ #
967
+ # Recording the cause matters: this runs inside Faraday's on_data callback
968
+ # on the SSE worker thread, whose generic rescue would otherwise leave
969
+ # callers with a bare "connection lost" and no reason. Mirrors the
970
+ # endpoint-URI failure path so wait_for_connection surfaces it promptly.
971
+ # @raise [MCPClient::Errors::ConnectionError] always
972
+ def fail_oversized_sse_buffer!
973
+ message = "SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
974
+ 'without a terminator'
975
+ @buffer = +''
976
+ @buffer_scanned = 0
977
+ @connection_error = message
978
+ @connection_established = false
979
+ @connection_cv.broadcast
980
+ @logger.error(message)
981
+ raise MCPClient::Errors::ConnectionError, message
982
+ end
983
+
887
984
  # Extract a single event from the buffer
888
985
  # @param event_end [Integer] the position where the event ends
889
986
  # @return [String] the extracted event data
@@ -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', {})
@@ -63,7 +66,7 @@ module MCPClient
63
66
  # @return [void]
64
67
  # @raise [MCPClient::Errors::TransportError] on write errors
65
68
  def send_request(req)
66
- @logger.debug("Sending JSONRPC request: #{req.to_json}")
69
+ @logger.debug("Sending JSONRPC request: #{describe_jsonrpc_message(req)}")
67
70
  @stdin.puts(req.to_json)
68
71
  rescue StandardError => e
69
72
  # A request that failed to send will never receive a response, so drop
@@ -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
- with_retry do
121
+ with_retry(method) 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