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