ruby-mcp-client 1.1.0 → 2.0.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 +121 -3
- data/lib/mcp_client/auth/oauth_provider.rb +236 -26
- data/lib/mcp_client/auth.rb +40 -9
- data/lib/mcp_client/client.rb +417 -139
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +35 -1
- data/lib/mcp_client/http_transport_base.rb +227 -37
- data/lib/mcp_client/json_rpc_common.rb +126 -13
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +63 -8
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +10 -3
- data/lib/mcp_client/server_sse/sse_parser.rb +46 -9
- data/lib/mcp_client/server_sse.rb +49 -21
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +29 -6
- data/lib/mcp_client/server_stdio.rb +92 -17
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +238 -77
- data/lib/mcp_client/tool.rb +18 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +3 -2
|
@@ -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', {})
|
|
@@ -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::
|
|
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
121
|
with_retry do
|
|
119
122
|
req_id = next_id
|
|
120
123
|
req = build_jsonrpc_request(method, params, req_id)
|
|
121
124
|
send_request(req)
|
|
122
|
-
|
|
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
|
|
@@ -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
|
|
@@ -142,6 +162,13 @@ module MCPClient
|
|
|
142
162
|
msg = JSON.parse(line)
|
|
143
163
|
@logger.debug("Received line: #{line.chomp}")
|
|
144
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
|
|
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')
|
|
147
174
|
handle_server_request(msg)
|
|
@@ -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
|
|
@@ -524,18 +564,19 @@ module MCPClient
|
|
|
524
564
|
# @param params [Hash] the elicitation parameters
|
|
525
565
|
# @return [void]
|
|
526
566
|
def handle_elicitation_create(request_id, params)
|
|
527
|
-
#
|
|
567
|
+
# Without a callback there is no user to interact with: answer with a
|
|
568
|
+
# JSON-RPC error rather than fabricating a user "decline".
|
|
528
569
|
unless @elicitation_request_callback
|
|
529
|
-
@logger.warn('Received elicitation request but no callback registered
|
|
530
|
-
|
|
570
|
+
@logger.warn('Received elicitation request but no callback registered')
|
|
571
|
+
send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
|
|
531
572
|
return
|
|
532
573
|
end
|
|
533
574
|
|
|
534
575
|
# Call the registered callback
|
|
535
576
|
result = @elicitation_request_callback.call(request_id, params)
|
|
536
577
|
|
|
537
|
-
# Send the response back to the server
|
|
538
|
-
send_elicitation_response(request_id, result)
|
|
578
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
579
|
+
send_elicitation_response(request_id, merge_related_task_meta(result, params))
|
|
539
580
|
end
|
|
540
581
|
|
|
541
582
|
# Handle roots/list request from server (MCP 2025-06-18)
|
|
@@ -553,8 +594,8 @@ module MCPClient
|
|
|
553
594
|
# Call the registered callback
|
|
554
595
|
result = @roots_list_request_callback.call(request_id, params)
|
|
555
596
|
|
|
556
|
-
# Send the response back to the server
|
|
557
|
-
send_roots_list_response(request_id, result)
|
|
597
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
598
|
+
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
558
599
|
end
|
|
559
600
|
|
|
560
601
|
# Handle sampling/createMessage request from server (MCP 2025-11-25)
|
|
@@ -572,8 +613,8 @@ module MCPClient
|
|
|
572
613
|
# Call the registered callback
|
|
573
614
|
result = @sampling_request_callback.call(request_id, params)
|
|
574
615
|
|
|
575
|
-
# Send the response back to the server
|
|
576
|
-
send_sampling_response(request_id, result)
|
|
616
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
617
|
+
send_sampling_response(request_id, merge_related_task_meta(result, params))
|
|
577
618
|
end
|
|
578
619
|
|
|
579
620
|
# Send roots/list response back to server (MCP 2025-06-18)
|
|
@@ -613,6 +654,14 @@ module MCPClient
|
|
|
613
654
|
# @param result [Hash] the elicitation result (action and optional content)
|
|
614
655
|
# @return [void]
|
|
615
656
|
def send_elicitation_response(request_id, result)
|
|
657
|
+
# Error-shaped results become JSON-RPC error responses (e.g. -32602 for
|
|
658
|
+
# an undeclared elicitation mode), mirroring the sampling error path.
|
|
659
|
+
if result.is_a?(Hash) && result['error']
|
|
660
|
+
send_error_response(request_id, result['error']['code'] || -32_603,
|
|
661
|
+
result['error']['message'] || 'Elicitation error')
|
|
662
|
+
return
|
|
663
|
+
end
|
|
664
|
+
|
|
616
665
|
response = {
|
|
617
666
|
'jsonrpc' => '2.0',
|
|
618
667
|
'id' => request_id,
|
|
@@ -673,17 +722,17 @@ module MCPClient
|
|
|
673
722
|
|
|
674
723
|
# Clean up the server connection
|
|
675
724
|
# Closes all stdio handles and terminates any running processes and threads
|
|
725
|
+
# following the MCP 2025-11-25 stdio shutdown sequence (basic/lifecycle.mdx):
|
|
726
|
+
# close stdin, wait for the server to exit, send SIGTERM if it does not exit
|
|
727
|
+
# within a reasonable time, then SIGKILL if it still does not exit.
|
|
676
728
|
# @return [void]
|
|
677
729
|
def cleanup
|
|
678
730
|
return unless @stdin
|
|
679
731
|
|
|
680
732
|
@stdin.close unless @stdin.closed?
|
|
733
|
+
terminate_server_process
|
|
681
734
|
@stdout.close unless @stdout.closed?
|
|
682
735
|
@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
736
|
@reader_thread&.kill
|
|
688
737
|
@stderr_thread&.kill
|
|
689
738
|
rescue StandardError
|
|
@@ -696,5 +745,31 @@ module MCPClient
|
|
|
696
745
|
end
|
|
697
746
|
@stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
|
|
698
747
|
end
|
|
748
|
+
|
|
749
|
+
# Terminate the spawned server process per the MCP 2025-11-25 stdio
|
|
750
|
+
# shutdown sequence (basic/lifecycle.mdx): stdin has already been closed,
|
|
751
|
+
# so wait for the process to exit on its own; if it does not exit within
|
|
752
|
+
# the grace period send SIGTERM, wait again, and finally send SIGKILL.
|
|
753
|
+
# @return [void]
|
|
754
|
+
def terminate_server_process
|
|
755
|
+
return unless @wait_thread
|
|
756
|
+
return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
757
|
+
|
|
758
|
+
signal_server_process('TERM')
|
|
759
|
+
return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
760
|
+
|
|
761
|
+
signal_server_process('KILL')
|
|
762
|
+
@wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Send a signal to the server process, tolerating a process that has
|
|
766
|
+
# already exited or cannot be signalled.
|
|
767
|
+
# @param signal [String] signal name, e.g. 'TERM' or 'KILL'
|
|
768
|
+
# @return [void]
|
|
769
|
+
def signal_server_process(signal)
|
|
770
|
+
Process.kill(signal, @wait_thread.pid)
|
|
771
|
+
rescue Errno::ESRCH, Errno::EPERM => e
|
|
772
|
+
@logger.debug("Could not send SIG#{signal} to server process: #{e.class}")
|
|
773
|
+
end
|
|
699
774
|
end
|
|
700
775
|
end
|
|
@@ -22,10 +22,11 @@ module MCPClient
|
|
|
22
22
|
|
|
23
23
|
# Parse a Streamable HTTP JSON-RPC response (JSON or SSE format)
|
|
24
24
|
# @param response [Faraday::Response] the HTTP response
|
|
25
|
+
# @param request [Hash, nil] the originating JSON-RPC request, used to match the response by id
|
|
25
26
|
# @return [Hash] the parsed result
|
|
26
27
|
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
|
27
28
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
28
|
-
def parse_response(response)
|
|
29
|
+
def parse_response(response, request = nil)
|
|
29
30
|
body = response.body
|
|
30
31
|
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
|
|
31
32
|
content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
|
|
@@ -36,7 +37,7 @@ module MCPClient
|
|
|
36
37
|
# Determine response format based on Content-Type header per MCP 2025 spec
|
|
37
38
|
data = if content_type.include?('text/event-stream')
|
|
38
39
|
# Parse SSE-formatted response for streaming
|
|
39
|
-
parse_sse_response(body)
|
|
40
|
+
parse_sse_response(body, request && request['id'])
|
|
40
41
|
else
|
|
41
42
|
# Parse regular JSON response (default for Streamable HTTP)
|
|
42
43
|
JSON.parse(body)
|
|
@@ -47,50 +48,175 @@ module MCPClient
|
|
|
47
48
|
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
|
48
49
|
end
|
|
49
50
|
|
|
50
|
-
# Parse Server-Sent Event formatted response
|
|
51
|
+
# Parse a Server-Sent Event formatted response body.
|
|
52
|
+
#
|
|
53
|
+
# Per MCP 2025-11-25, the server MAY send JSON-RPC requests and
|
|
54
|
+
# notifications on the POST response stream before the response, and MAY
|
|
55
|
+
# send priming events carrying only an event id. Every interleaved server
|
|
56
|
+
# message is dispatched exactly like on the GET events stream; the
|
|
57
|
+
# JSON-RPC response matching the originating request id is returned.
|
|
58
|
+
#
|
|
51
59
|
# @param sse_body [String] the SSE formatted response body
|
|
52
|
-
# @
|
|
53
|
-
# @
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
# @param request_id [Integer, String, nil] id of the originating request
|
|
61
|
+
# @return [Hash] the parsed JSON-RPC response
|
|
62
|
+
# @raise [MCPClient::Errors::TransportError] if no response is found
|
|
63
|
+
def parse_sse_response(sse_body, request_id = nil)
|
|
64
|
+
events, retry_ms = extract_sse_events(sse_body)
|
|
65
|
+
|
|
66
|
+
raise MCPClient::Errors::TransportError, 'No data found in SSE response' if events.empty?
|
|
67
|
+
|
|
68
|
+
responses, saw_invalid_json = route_sse_events(events)
|
|
69
|
+
matched = select_sse_response(responses, request_id)
|
|
70
|
+
return matched if matched
|
|
71
|
+
|
|
72
|
+
if saw_invalid_json
|
|
73
|
+
raise MCPClient::Errors::TransportError,
|
|
74
|
+
'Invalid JSON response from server: SSE stream contained no valid JSON-RPC response'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
resume_or_fail(events, request_id, retry_ms)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# SEP-1699 polling pattern: the server MAY close the POST stream before
|
|
81
|
+
# delivering the response. When a cursor was received, resume via HTTP
|
|
82
|
+
# GET with Last-Event-ID instead of re-POSTing the (possibly
|
|
83
|
+
# non-idempotent) request.
|
|
84
|
+
# @param events [Array<Hash>] parsed SSE events
|
|
85
|
+
# @param request_id [Integer, String, nil] id of the originating request
|
|
86
|
+
# @param retry_ms [Integer, nil] retry directive received on THIS stream
|
|
87
|
+
# @return [Hash] the replayed JSON-RPC response
|
|
88
|
+
# @raise [MCPClient::Errors::ServerError] when resumption fails
|
|
89
|
+
# @raise [MCPClient::Errors::TransportError] when no cursor was received
|
|
90
|
+
def resume_or_fail(events, request_id, retry_ms = nil)
|
|
91
|
+
cursor = events.reverse.find { |e| e[:id] && !e[:id].empty? }&.dig(:id)
|
|
92
|
+
if request_id && cursor
|
|
93
|
+
# Resume with THIS stream's cursor and retry directive (both are
|
|
94
|
+
# per-stream), not the shared @last_event_id / @sse_retry_ms which a
|
|
95
|
+
# concurrent stream may have moved between parsing and resumption.
|
|
96
|
+
resumed = resume_response_via_get(request_id, cursor, retry_ms)
|
|
97
|
+
return resumed if resumed
|
|
98
|
+
|
|
99
|
+
# Non-retryable: the request may already be executing server-side,
|
|
100
|
+
# so a blind re-POST could run a non-idempotent operation twice.
|
|
101
|
+
raise MCPClient::Errors::ServerError,
|
|
102
|
+
'SSE stream closed before delivering the response and resumption via GET failed'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
raise MCPClient::Errors::TransportError, 'No JSON-RPC response found in SSE response'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Split an SSE body into events. An event without an explicit `event:`
|
|
109
|
+
# field has the default type "message" per the SSE specification; events
|
|
110
|
+
# carrying only an id (priming events) are kept so their id is tracked.
|
|
111
|
+
# @param sse_body [String] the SSE formatted response body
|
|
112
|
+
# @return [Array(Array<Hash>, Integer, nil)] parsed events and the last
|
|
113
|
+
# retry directive (ms) received on this stream, if any
|
|
114
|
+
def extract_sse_events(sse_body)
|
|
57
115
|
events = []
|
|
58
|
-
|
|
116
|
+
retry_ms = nil
|
|
117
|
+
current_event = { type: 'message', data_lines: [], id: nil }
|
|
59
118
|
|
|
60
119
|
sse_body.lines.each do |line|
|
|
61
120
|
line = line.strip
|
|
62
121
|
|
|
63
122
|
if line.empty?
|
|
64
123
|
# Empty line marks end of an event
|
|
65
|
-
events << current_event.dup if current_event
|
|
66
|
-
current_event = { type:
|
|
124
|
+
events << current_event.dup if sse_event_present?(current_event)
|
|
125
|
+
current_event = { type: 'message', data_lines: [], id: nil }
|
|
67
126
|
elsif line.start_with?('event:')
|
|
68
127
|
current_event[:type] = line.sub(/^event:\s*/, '').strip
|
|
69
128
|
elsif line.start_with?('data:')
|
|
70
129
|
current_event[:data_lines] << line.sub(/^data:\s*/, '').strip
|
|
71
130
|
elsif line.start_with?('id:')
|
|
72
131
|
current_event[:id] = line.sub(/^id:\s*/, '').strip
|
|
132
|
+
elsif line.start_with?('retry:')
|
|
133
|
+
# SEP-1699: the client MUST respect the server's retry directive.
|
|
134
|
+
# Track it locally for this stream's resumption; the shared ivar is
|
|
135
|
+
# only a hint for the general events loop.
|
|
136
|
+
raw = line.sub(/^retry:\s*/, '').strip
|
|
137
|
+
if raw.match?(/\A\d+\z/)
|
|
138
|
+
retry_ms = raw.to_i
|
|
139
|
+
@sse_retry_ms = retry_ms
|
|
140
|
+
end
|
|
73
141
|
end
|
|
74
142
|
end
|
|
75
143
|
|
|
76
144
|
# Handle last event if no trailing empty line
|
|
77
|
-
events << current_event if current_event
|
|
145
|
+
events << current_event if sse_event_present?(current_event)
|
|
146
|
+
[events, retry_ms]
|
|
147
|
+
end
|
|
78
148
|
|
|
79
|
-
|
|
80
|
-
|
|
149
|
+
# @param event [Hash] a parsed SSE event
|
|
150
|
+
# @return [Boolean] whether the event carries any data or id
|
|
151
|
+
def sse_event_present?(event)
|
|
152
|
+
(event[:id] && !event[:id].empty?) || !event[:data_lines].empty?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Track event ids for resumability, dispatch interleaved server messages
|
|
156
|
+
# (requests, notifications, pings) and collect response candidates.
|
|
157
|
+
# @param events [Array<Hash>] parsed SSE events
|
|
158
|
+
# @return [Array(Array<Hash>, Boolean)] response candidates and whether invalid JSON was seen
|
|
159
|
+
def route_sse_events(events)
|
|
160
|
+
responses = []
|
|
161
|
+
saw_invalid_json = false
|
|
162
|
+
|
|
163
|
+
events.each do |event|
|
|
164
|
+
if event[:id] && !event[:id].empty?
|
|
165
|
+
@mutex.synchronize { @last_event_id = event[:id] }
|
|
166
|
+
@logger.debug("Tracking event ID for resumability: #{event[:id]}")
|
|
167
|
+
end
|
|
168
|
+
next unless event[:type] == 'message'
|
|
169
|
+
|
|
170
|
+
message = parse_sse_event_data(event[:data_lines].join("\n"))
|
|
171
|
+
saw_invalid_json = true if message == :invalid
|
|
172
|
+
next unless message.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
if message['method']
|
|
175
|
+
dispatch_server_message(message)
|
|
176
|
+
else
|
|
177
|
+
responses << message
|
|
178
|
+
end
|
|
179
|
+
end
|
|
81
180
|
|
|
82
|
-
|
|
83
|
-
|
|
181
|
+
[responses, saw_invalid_json]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Parse the data payload of a single SSE event.
|
|
185
|
+
# @param json_data [String] the joined data lines
|
|
186
|
+
# @return [Hash, Symbol, nil] the parsed message, :invalid, or nil for empty/non-object data
|
|
187
|
+
def parse_sse_event_data(json_data)
|
|
188
|
+
return nil if json_data.empty?
|
|
189
|
+
|
|
190
|
+
message = JSON.parse(json_data)
|
|
191
|
+
return message if message.is_a?(Hash)
|
|
192
|
+
|
|
193
|
+
@logger.warn("Skipping non-object JSON-RPC message in SSE event: #{message.inspect}")
|
|
194
|
+
nil
|
|
195
|
+
rescue JSON::ParserError => e
|
|
196
|
+
@logger.warn("Skipping invalid JSON in SSE event: #{e.message}")
|
|
197
|
+
:invalid
|
|
198
|
+
end
|
|
84
199
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
200
|
+
# Choose the JSON-RPC response answering the originating request.
|
|
201
|
+
# @param responses [Array<Hash>] response candidates from the stream
|
|
202
|
+
# @param request_id [Integer, String, nil] id of the originating request
|
|
203
|
+
# @return [Hash, nil] the selected response, if any
|
|
204
|
+
def select_sse_response(responses, request_id)
|
|
205
|
+
matched = if request_id.nil?
|
|
206
|
+
responses.first
|
|
207
|
+
else
|
|
208
|
+
responses.find { |msg| msg['id'] == request_id || msg['id'].to_s == request_id.to_s }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
if matched.nil? && responses.length == 1
|
|
212
|
+
matched = responses.first
|
|
213
|
+
@logger.warn(
|
|
214
|
+
"SSE response id #{matched['id'].inspect} does not match request id #{request_id.inspect}; " \
|
|
215
|
+
'accepting the only response on the stream'
|
|
216
|
+
)
|
|
89
217
|
end
|
|
90
218
|
|
|
91
|
-
|
|
92
|
-
json_data = message_event[:data_lines].join("\n")
|
|
93
|
-
JSON.parse(json_data)
|
|
219
|
+
matched
|
|
94
220
|
end
|
|
95
221
|
end
|
|
96
222
|
end
|