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.
- checksums.yaml +4 -4
- data/README.md +216 -10
- data/lib/mcp_client/auth/oauth_provider.rb +325 -27
- data/lib/mcp_client/auth.rb +38 -11
- data/lib/mcp_client/client.rb +523 -150
- data/lib/mcp_client/elicitation_validator.rb +99 -13
- data/lib/mcp_client/errors.rb +43 -1
- data/lib/mcp_client/http_transport_base.rb +254 -41
- data/lib/mcp_client/json_rpc_common.rb +196 -14
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +13 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +14 -3
- data/lib/mcp_client/resource_template.rb +13 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +329 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
- data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
- data/lib/mcp_client/server_sse.rb +132 -35
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
- data/lib/mcp_client/server_stdio.rb +98 -20
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
- data/lib/mcp_client/server_streamable_http.rb +472 -108
- data/lib/mcp_client/tool.rb +16 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +9 -1
- 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: #{
|
|
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' =>
|
|
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' =>
|
|
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,
|
|
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
|
-
#
|
|
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
|
|
530
|
-
|
|
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: #{
|
|
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 =
|
|
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
|
|
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
|
-
#
|
|
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]
|
|
53
|
-
#
|
|
54
|
-
def
|
|
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
|
-
|
|
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
|
|
66
|
-
current_event = { type:
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
@
|
|
88
|
-
|
|
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
|
-
|
|
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
|