ruby-mcp-client 2.0.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.
- checksums.yaml +4 -4
- data/README.md +95 -7
- data/lib/mcp_client/auth/oauth_provider.rb +91 -3
- data/lib/mcp_client/auth.rb +0 -4
- data/lib/mcp_client/client.rb +109 -14
- data/lib/mcp_client/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +8 -0
- data/lib/mcp_client/http_transport_base.rb +29 -6
- data/lib/mcp_client/json_rpc_common.rb +70 -1
- data/lib/mcp_client/resource.rb +0 -2
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +0 -2
- data/lib/mcp_client/schema_validator.rb +56 -12
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +1 -1
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +34 -6
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
- data/lib/mcp_client/server_sse/sse_parser.rb +38 -7
- data/lib/mcp_client/server_sse.rb +83 -14
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +2 -2
- data/lib/mcp_client/server_stdio.rb +6 -3
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +77 -7
- data/lib/mcp_client/server_streamable_http.rb +246 -43
- data/lib/mcp_client/tool.rb +0 -2
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +8 -1
- metadata +4 -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,8 +98,14 @@ 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
|
|
@@ -423,7 +436,18 @@ module MCPClient
|
|
|
423
436
|
|
|
424
437
|
# Reset the SSE parse buffer so a reconnect never inherits a leftover
|
|
425
438
|
# partial event from the previous connection.
|
|
426
|
-
@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) }
|
|
427
451
|
|
|
428
452
|
# Log cleanup for debugging
|
|
429
453
|
@logger.debug('Cleaning up SSE connection')
|
|
@@ -519,8 +543,11 @@ module MCPClient
|
|
|
519
543
|
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
520
544
|
end
|
|
521
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.
|
|
522
549
|
@logger.error("Error handling server request: #{e.message}")
|
|
523
|
-
send_error_response(request_id, -32_603,
|
|
550
|
+
send_error_response(request_id, -32_603, 'Internal error')
|
|
524
551
|
end
|
|
525
552
|
|
|
526
553
|
# Handle a server-initiated ping request (MCP ping utility)
|
|
@@ -717,7 +744,7 @@ module MCPClient
|
|
|
717
744
|
req.body = json_body
|
|
718
745
|
end
|
|
719
746
|
|
|
720
|
-
@logger.debug("Sent response via HTTP POST: #{
|
|
747
|
+
@logger.debug("Sent response via HTTP POST: #{describe_jsonrpc_message(response)}")
|
|
721
748
|
rescue StandardError => e
|
|
722
749
|
@logger.error("Failed to send response via HTTP POST: #{e.message}")
|
|
723
750
|
end
|
|
@@ -839,7 +866,9 @@ module MCPClient
|
|
|
839
866
|
# Process an SSE chunk from the server
|
|
840
867
|
# @param chunk [String] the chunk to process
|
|
841
868
|
def process_sse_chunk(chunk)
|
|
842
|
-
|
|
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)})")
|
|
843
872
|
|
|
844
873
|
# Only record activity for real events
|
|
845
874
|
record_activity if chunk.include?('event:')
|
|
@@ -897,21 +926,61 @@ module MCPClient
|
|
|
897
926
|
# @return [Array<String>, nil] array of complete events or nil if none
|
|
898
927
|
# @private
|
|
899
928
|
def extract_complete_events(chunk)
|
|
900
|
-
event_buffers =
|
|
929
|
+
event_buffers = []
|
|
901
930
|
@mutex.synchronize do
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
#
|
|
905
|
-
#
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
|
910
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
|
|
911
952
|
end
|
|
912
953
|
event_buffers
|
|
913
954
|
end
|
|
914
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
|
+
|
|
915
984
|
# Extract a single event from the buffer
|
|
916
985
|
# @param event_end [Integer] the position where the event ends
|
|
917
986
|
# @return [String] the extracted event data
|
|
@@ -66,7 +66,7 @@ module MCPClient
|
|
|
66
66
|
# @return [void]
|
|
67
67
|
# @raise [MCPClient::Errors::TransportError] on write errors
|
|
68
68
|
def send_request(req)
|
|
69
|
-
@logger.debug("Sending JSONRPC request: #{req
|
|
69
|
+
@logger.debug("Sending JSONRPC request: #{describe_jsonrpc_message(req)}")
|
|
70
70
|
@stdin.puts(req.to_json)
|
|
71
71
|
rescue StandardError => e
|
|
72
72
|
# A request that failed to send will never receive a response, so drop
|
|
@@ -118,7 +118,7 @@ module MCPClient
|
|
|
118
118
|
# @raise [MCPClient::Errors::ToolCallError] on tool call errors
|
|
119
119
|
def rpc_request(method, params = {}, timeout: nil)
|
|
120
120
|
ensure_initialized
|
|
121
|
-
with_retry do
|
|
121
|
+
with_retry(method) do
|
|
122
122
|
req_id = next_id
|
|
123
123
|
req = build_jsonrpc_request(method, params, req_id)
|
|
124
124
|
send_request(req)
|
|
@@ -160,7 +160,7 @@ module MCPClient
|
|
|
160
160
|
# @return [void]
|
|
161
161
|
def handle_line(line)
|
|
162
162
|
msg = JSON.parse(line)
|
|
163
|
-
@logger.debug("Received line: #{
|
|
163
|
+
@logger.debug("Received line: #{describe_jsonrpc_message(msg)}")
|
|
164
164
|
|
|
165
165
|
# A JSON-parseable line that is not an object cannot be a JSON-RPC
|
|
166
166
|
# message; skip it rather than raising inside the reader thread
|
|
@@ -542,8 +542,11 @@ module MCPClient
|
|
|
542
542
|
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
543
543
|
end
|
|
544
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.
|
|
545
548
|
@logger.error("Error handling server request: #{e.message}")
|
|
546
|
-
send_error_response(request_id, -32_603,
|
|
549
|
+
send_error_response(request_id, -32_603, 'Internal error')
|
|
547
550
|
end
|
|
548
551
|
|
|
549
552
|
# Handle a server-initiated ping request (MCP ping utility)
|
|
@@ -694,7 +697,7 @@ module MCPClient
|
|
|
694
697
|
json = JSON.generate(message)
|
|
695
698
|
@stdin.puts(json)
|
|
696
699
|
@stdin.flush
|
|
697
|
-
@logger.debug("Sent message: #{
|
|
700
|
+
@logger.debug("Sent message: #{describe_jsonrpc_message(message)}")
|
|
698
701
|
rescue StandardError => e
|
|
699
702
|
@logger.error("Error sending message: #{e.message}")
|
|
700
703
|
end
|
|
@@ -12,12 +12,45 @@ 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)
|
|
@@ -31,7 +64,7 @@ module MCPClient
|
|
|
31
64
|
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
|
|
32
65
|
content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
|
|
33
66
|
|
|
34
|
-
body =
|
|
67
|
+
body = decompress_gzip(body) if content_encoding.include?('gzip')
|
|
35
68
|
body = body&.strip
|
|
36
69
|
|
|
37
70
|
# Determine response format based on Content-Type header per MCP 2025 spec
|
|
@@ -45,7 +78,37 @@ module MCPClient
|
|
|
45
78
|
|
|
46
79
|
process_jsonrpc_response(data)
|
|
47
80
|
rescue JSON::ParserError => e
|
|
48
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
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
|
|
49
112
|
end
|
|
50
113
|
|
|
51
114
|
# Parse a Server-Sent Event formatted response body.
|
|
@@ -88,7 +151,9 @@ module MCPClient
|
|
|
88
151
|
# @raise [MCPClient::Errors::ServerError] when resumption fails
|
|
89
152
|
# @raise [MCPClient::Errors::TransportError] when no cursor was received
|
|
90
153
|
def resume_or_fail(events, request_id, retry_ms = nil)
|
|
91
|
-
|
|
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)
|
|
92
157
|
if request_id && cursor
|
|
93
158
|
# Resume with THIS stream's cursor and retry directive (both are
|
|
94
159
|
# per-stream), not the shared @last_event_id / @sse_retry_ms which a
|
|
@@ -162,7 +227,10 @@ module MCPClient
|
|
|
162
227
|
|
|
163
228
|
events.each do |event|
|
|
164
229
|
if event[:id] && !event[:id].empty?
|
|
165
|
-
|
|
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])
|
|
166
234
|
@logger.debug("Tracking event ID for resumability: #{event[:id]}")
|
|
167
235
|
end
|
|
168
236
|
next unless event[:type] == 'message'
|
|
@@ -190,10 +258,12 @@ module MCPClient
|
|
|
190
258
|
message = JSON.parse(json_data)
|
|
191
259
|
return message if message.is_a?(Hash)
|
|
192
260
|
|
|
193
|
-
|
|
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})")
|
|
194
264
|
nil
|
|
195
265
|
rescue JSON::ParserError => e
|
|
196
|
-
@logger.warn("Skipping invalid JSON in SSE event: #{e
|
|
266
|
+
@logger.warn("Skipping invalid JSON in SSE event: #{describe_parse_error(e, json_data)}")
|
|
197
267
|
:invalid
|
|
198
268
|
end
|
|
199
269
|
|