mcp 0.22.0 → 0.24.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 +324 -10
- data/lib/json_rpc_handler.rb +17 -0
- data/lib/mcp/apps.rb +109 -0
- data/lib/mcp/client/elicitation.rb +51 -0
- data/lib/mcp/client/http.rb +326 -31
- data/lib/mcp/client/oauth/client_credentials_provider.rb +72 -14
- data/lib/mcp/client/oauth/flow.rb +148 -5
- data/lib/mcp/client/oauth/in_memory_storage.rb +3 -1
- data/lib/mcp/client/oauth/jwt_client_assertion.rb +128 -0
- data/lib/mcp/client/oauth/provider.rb +11 -3
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/client/paginated_result.rb +7 -5
- data/lib/mcp/client/stdio.rb +47 -11
- data/lib/mcp/client.rb +89 -1
- data/lib/mcp/error_codes.rb +21 -0
- data/lib/mcp/methods.rb +7 -1
- data/lib/mcp/resource.rb +135 -0
- data/lib/mcp/resource_template.rb +149 -0
- data/lib/mcp/result_type.rb +18 -0
- data/lib/mcp/server/transports/stdio_transport.rb +51 -3
- data/lib/mcp/server/transports/streamable_http_transport.rb +385 -94
- data/lib/mcp/server.rb +205 -20
- data/lib/mcp/server_context.rb +22 -0
- data/lib/mcp/server_session.rb +50 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +7 -2
data/lib/mcp/client/http.rb
CHANGED
|
@@ -8,16 +8,27 @@ require_relative "../version"
|
|
|
8
8
|
|
|
9
9
|
module MCP
|
|
10
10
|
class Client
|
|
11
|
-
# TODO: HTTP GET for SSE streaming is not yet implemented.
|
|
12
|
-
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
|
|
13
|
-
# TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
|
|
14
|
-
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
|
|
15
11
|
class HTTP
|
|
16
12
|
ACCEPT_HEADER = "application/json, text/event-stream"
|
|
13
|
+
SSE_ACCEPT_HEADER = "text/event-stream"
|
|
17
14
|
SESSION_ID_HEADER = "Mcp-Session-Id"
|
|
18
15
|
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
|
19
16
|
METHOD_HEADER = "Mcp-Method"
|
|
20
17
|
NAME_HEADER = "Mcp-Name"
|
|
18
|
+
LAST_EVENT_ID_HEADER = "Last-Event-ID"
|
|
19
|
+
|
|
20
|
+
# SEP-1699 reconnection tuning: the SSE `retry:` field from the server takes precedence;
|
|
21
|
+
# this default applies when the server sent none. Both values match the Python SDK
|
|
22
|
+
# (`DEFAULT_RECONNECTION_DELAY_MS`, `MAX_RECONNECTION_ATTEMPTS`); the TypeScript SDK uses
|
|
23
|
+
# an exponential backoff that the `retry:` field likewise overrides.
|
|
24
|
+
DEFAULT_RECONNECTION_DELAY_MS = 1000
|
|
25
|
+
MAX_RECONNECTION_ATTEMPTS = 2
|
|
26
|
+
|
|
27
|
+
# How long the standalone GET listening stream may stay idle before the read times out
|
|
28
|
+
# and the connection is counted as a failure and retried. Matches the Python SDK's
|
|
29
|
+
# `sse_read_timeout` default of 5 minutes; without this, the adapter's default read timeout
|
|
30
|
+
# (60 seconds for Net::HTTP) would recycle quiet streams too eagerly.
|
|
31
|
+
SSE_LISTENER_READ_TIMEOUT = 300
|
|
21
32
|
|
|
22
33
|
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
|
23
34
|
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
|
@@ -54,6 +65,108 @@ module MCP
|
|
|
54
65
|
end
|
|
55
66
|
end
|
|
56
67
|
|
|
68
|
+
# Internal control-flow signal raised inside the streaming callback to stop reading
|
|
69
|
+
# an SSE stream once the awaited JSON-RPC response has arrived; servers may hold
|
|
70
|
+
# a stream open indefinitely (notably the standalone GET stream), so EOF cannot be relied on.
|
|
71
|
+
class StreamAbort < StandardError; end
|
|
72
|
+
private_constant :StreamAbort
|
|
73
|
+
|
|
74
|
+
# Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams:
|
|
75
|
+
# the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
|
|
76
|
+
# and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
|
|
77
|
+
class SSEStream
|
|
78
|
+
attr_reader :buffer, :response, :last_event_id, :retry_ms
|
|
79
|
+
attr_accessor :abortable
|
|
80
|
+
|
|
81
|
+
def initialize(abortable:, on_request: nil)
|
|
82
|
+
@abortable = abortable
|
|
83
|
+
@on_request = on_request
|
|
84
|
+
@buffer = +""
|
|
85
|
+
@parser = nil
|
|
86
|
+
@response = nil
|
|
87
|
+
@last_event_id = nil
|
|
88
|
+
@retry_ms = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Whether the server sent a SEP-1699 priming event (any event carrying an id),
|
|
92
|
+
# which marks the stream as resumable after a graceful close.
|
|
93
|
+
def primed?
|
|
94
|
+
!@last_event_id.nil?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Faraday `on_data` streaming callback. SSE chunks are parsed incrementally;
|
|
98
|
+
# anything else (JSON bodies) accumulates in `buffer`.
|
|
99
|
+
def on_data
|
|
100
|
+
proc do |chunk, _received_bytes, env|
|
|
101
|
+
if event_stream?(env)
|
|
102
|
+
feed(chunk)
|
|
103
|
+
raise StreamAbort if @abortable && @response
|
|
104
|
+
else
|
|
105
|
+
@buffer << chunk
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A fresh parser for a new HTTP connection (reconnection GET),
|
|
111
|
+
# so a partial line from the previous stream cannot corrupt the next one.
|
|
112
|
+
def reset_parser!
|
|
113
|
+
@parser = nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Parses an SSE body that was delivered outside the streaming callback:
|
|
117
|
+
# Faraday versions that do not pass `env` to `on_data` leave SSE chunks in `buffer`
|
|
118
|
+
# (consumed here so they are not parsed twice), and adapters without `on_data` support
|
|
119
|
+
# yield the whole body only via `fallback_body` (the Faraday `response.body`).
|
|
120
|
+
def ingest_pending!(fallback_body)
|
|
121
|
+
text = @buffer.empty? ? fallback_body : @buffer.slice!(0..-1)
|
|
122
|
+
feed(text) if text.is_a?(String) && !text.empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def event_stream?(env)
|
|
128
|
+
headers = env&.response_headers
|
|
129
|
+
content_type = headers && (headers["content-type"] || headers["Content-Type"])
|
|
130
|
+
!!content_type&.include?("text/event-stream")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def feed(chunk)
|
|
134
|
+
parser.feed(chunk) do |_type, data, id, reconnection_time|
|
|
135
|
+
@last_event_id = id if id && !id.empty?
|
|
136
|
+
@retry_ms = reconnection_time if reconnection_time
|
|
137
|
+
next if data.nil? || data.empty?
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
parsed = JSON.parse(data)
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
next unless parsed.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
if parsed.key?("result") || parsed.key?("error")
|
|
148
|
+
@response ||= parsed
|
|
149
|
+
elsif parsed["method"] && parsed.key?("id")
|
|
150
|
+
# A server-to-client request (e.g. `elicitation/create`) delivered
|
|
151
|
+
# on the stream while the original request is still pending.
|
|
152
|
+
@on_request&.call(parsed)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parser
|
|
158
|
+
@parser ||= begin
|
|
159
|
+
require "event_stream_parser"
|
|
160
|
+
EventStreamParser::Parser.new
|
|
161
|
+
rescue LoadError
|
|
162
|
+
raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
|
|
163
|
+
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
|
|
164
|
+
"See https://rubygems.org/gems/event_stream_parser for more details."
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
private_constant :SSEStream
|
|
169
|
+
|
|
57
170
|
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
|
|
58
171
|
|
|
59
172
|
def initialize(url:, headers: {}, oauth: nil, &block)
|
|
@@ -91,6 +204,24 @@ module MCP
|
|
|
91
204
|
@protocol_version = nil
|
|
92
205
|
@server_info = nil
|
|
93
206
|
@connected = false
|
|
207
|
+
@server_request_handlers = {}
|
|
208
|
+
@listener_thread = nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream.
|
|
212
|
+
# The handler receives the request's `params` (a Hash with string keys, possibly empty) and its return value is
|
|
213
|
+
# sent back to the server as the JSON-RPC `result`.
|
|
214
|
+
# The handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code.
|
|
215
|
+
# Requests for methods without a registered handler are answered with a JSON-RPC "method not found" (-32601) error.
|
|
216
|
+
# Registering a handler opens a standalone GET SSE listening stream (once connected), since servers send requests
|
|
217
|
+
# that are not tied to a client request on that stream - matching the TypeScript and Python SDK clients,
|
|
218
|
+
# which start listening after the `initialize` handshake.
|
|
219
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
|
|
220
|
+
def on_server_request(method, &handler)
|
|
221
|
+
raise ArgumentError, "A handler block is required" unless handler
|
|
222
|
+
|
|
223
|
+
@server_request_handlers[method.to_s] = handler
|
|
224
|
+
start_listening if connected?
|
|
94
225
|
end
|
|
95
226
|
|
|
96
227
|
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
@@ -169,6 +300,7 @@ module MCP
|
|
|
169
300
|
end
|
|
170
301
|
|
|
171
302
|
@connected = true
|
|
303
|
+
start_listening if @server_request_handlers.any?
|
|
172
304
|
@server_info
|
|
173
305
|
end
|
|
174
306
|
|
|
@@ -199,11 +331,27 @@ module MCP
|
|
|
199
331
|
step_up_retried = false
|
|
200
332
|
|
|
201
333
|
begin
|
|
334
|
+
# The response is consumed incrementally so that an SSE stream the server holds open
|
|
335
|
+
# (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF
|
|
336
|
+
# so the response object (and its `Mcp-Session-Id` header) is always available for capture.
|
|
337
|
+
stream = SSEStream.new(
|
|
338
|
+
abortable: method.to_s != MCP::Methods::INITIALIZE,
|
|
339
|
+
on_request: ->(message) { dispatch_server_request(message) },
|
|
340
|
+
)
|
|
341
|
+
|
|
202
342
|
yield if block_given?
|
|
203
|
-
response = client.post("", request, session_headers.merge(request_metadata_headers(method, params)))
|
|
204
|
-
body = parse_response_body(response, method, params)
|
|
205
343
|
|
|
206
|
-
|
|
344
|
+
response = begin
|
|
345
|
+
client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
|
|
346
|
+
req.options.on_data = stream.on_data
|
|
347
|
+
end
|
|
348
|
+
rescue StreamAbort
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
body = resolve_response_body(stream, response, method, params)
|
|
353
|
+
|
|
354
|
+
capture_session_info(method, response, body) if response
|
|
207
355
|
|
|
208
356
|
body
|
|
209
357
|
rescue Faraday::BadRequestError => e
|
|
@@ -522,12 +670,64 @@ module MCP
|
|
|
522
670
|
end
|
|
523
671
|
|
|
524
672
|
def clear_session
|
|
673
|
+
stop_listening
|
|
525
674
|
@session_id = nil
|
|
526
675
|
@protocol_version = nil
|
|
527
676
|
@server_info = nil
|
|
528
677
|
@connected = false
|
|
529
678
|
end
|
|
530
679
|
|
|
680
|
+
# Opens the standalone GET SSE listening stream on a background thread so server-to-client requests
|
|
681
|
+
# can arrive while the main thread is blocked on its own request (e.g. a tools/call awaiting elicitation).
|
|
682
|
+
# Like the TypeScript and Python SDK clients, the GET is fired without waiting for it to be established.
|
|
683
|
+
def start_listening
|
|
684
|
+
return if @listener_thread&.alive?
|
|
685
|
+
|
|
686
|
+
@listener_thread = Thread.new { listen_for_server_requests }
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def stop_listening
|
|
690
|
+
@listener_thread&.kill
|
|
691
|
+
@listener_thread = nil
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Reconnection semantics match the TypeScript and Python SDK clients: a stream that was established and
|
|
695
|
+
# then closed gracefully is retried indefinitely after the server's `retry:` interval (SSE semantics),
|
|
696
|
+
# while consecutive connection failures - HTTP errors and idle read timeouts both count - stop the listener
|
|
697
|
+
# once they reach `MAX_RECONNECTION_ATTEMPTS`; a successful stream in between resets the count.
|
|
698
|
+
# A 405 stops immediately without retrying, since it is the spec's answer from a server that offers no listening stream.
|
|
699
|
+
def listen_for_server_requests
|
|
700
|
+
stream = SSEStream.new(
|
|
701
|
+
abortable: false,
|
|
702
|
+
on_request: ->(message) { dispatch_server_request(message) },
|
|
703
|
+
)
|
|
704
|
+
consecutive_failures = 0
|
|
705
|
+
|
|
706
|
+
loop do
|
|
707
|
+
begin
|
|
708
|
+
client.get("") do |request|
|
|
709
|
+
request.headers.update(session_headers)
|
|
710
|
+
request.headers["Accept"] = SSE_ACCEPT_HEADER
|
|
711
|
+
request.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
|
|
712
|
+
request.options.read_timeout = SSE_LISTENER_READ_TIMEOUT
|
|
713
|
+
request.options.on_data = stream.on_data
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
consecutive_failures = 0
|
|
717
|
+
rescue Faraday::Error => e
|
|
718
|
+
break if e.response&.dig(:status) == 405
|
|
719
|
+
|
|
720
|
+
consecutive_failures += 1
|
|
721
|
+
|
|
722
|
+
break if consecutive_failures >= MAX_RECONNECTION_ATTEMPTS
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
stream.reset_parser!
|
|
726
|
+
|
|
727
|
+
sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0)
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
531
731
|
def require_faraday!
|
|
532
732
|
require "faraday"
|
|
533
733
|
rescue LoadError
|
|
@@ -536,15 +736,11 @@ module MCP
|
|
|
536
736
|
"See https://rubygems.org/gems/faraday for more details."
|
|
537
737
|
end
|
|
538
738
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
|
|
544
|
-
"See https://rubygems.org/gems/event_stream_parser for more details."
|
|
545
|
-
end
|
|
739
|
+
# Determines the logical JSON-RPC body of the exchange after the POST stream finished
|
|
740
|
+
# (or was aborted because the response already arrived).
|
|
741
|
+
def resolve_response_body(stream, response, method, params)
|
|
742
|
+
return stream.response if stream.response
|
|
546
743
|
|
|
547
|
-
def parse_response_body(response, method, params)
|
|
548
744
|
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
|
|
549
745
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
550
746
|
return if response.status == 202
|
|
@@ -552,9 +748,29 @@ module MCP
|
|
|
552
748
|
content_type = response.headers["Content-Type"]
|
|
553
749
|
|
|
554
750
|
if content_type&.include?("text/event-stream")
|
|
555
|
-
|
|
751
|
+
# Nothing was parsed during streaming: either the adapter does not support `on_data`
|
|
752
|
+
# (the whole body sits in `response.body`) or Faraday did not pass `env` to `on_data`
|
|
753
|
+
# (Faraday < 2.1; the SSE chunks sit in `buffer`). Parse whichever holds the body.
|
|
754
|
+
unless stream.primed?
|
|
755
|
+
stream.ingest_pending!(response.body)
|
|
756
|
+
return stream.response if stream.response
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# SEP-1699: a graceful close after a priming event (an event with an id) but before
|
|
760
|
+
# the response means "reconnect via GET to resume".
|
|
761
|
+
return await_response_after_disconnect(stream, method, params) if stream.primed?
|
|
762
|
+
|
|
763
|
+
raise RequestHandlerError.new(
|
|
764
|
+
"No valid JSON-RPC response found in SSE stream",
|
|
765
|
+
{ method: method, params: params },
|
|
766
|
+
error_type: :parse_error,
|
|
767
|
+
)
|
|
556
768
|
elsif content_type&.include?("application/json")
|
|
557
|
-
|
|
769
|
+
return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty?
|
|
770
|
+
|
|
771
|
+
# Adapters without `on_data` support deliver the body via `response.body`,
|
|
772
|
+
# already parsed by the json response middleware.
|
|
773
|
+
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
|
|
558
774
|
else
|
|
559
775
|
raise RequestHandlerError.new(
|
|
560
776
|
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
|
|
@@ -564,30 +780,109 @@ module MCP
|
|
|
564
780
|
end
|
|
565
781
|
end
|
|
566
782
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
783
|
+
# Answers a server-to-client request received on an SSE stream by invoking the handler registered via
|
|
784
|
+
# `on_server_request` and POSTing its return value back as a JSON-RPC response; the server ACKs with
|
|
785
|
+
# 202 Accepted. Requests without a registered handler are answered with a "method not found" error.
|
|
786
|
+
# A handler may raise `ServerRequestError` to answer with a specific JSON-RPC error code
|
|
787
|
+
# (e.g. `-1` for a rejected sampling request); any other error is answered with an "internal error".
|
|
788
|
+
# In every case the server is not left waiting, matching how the TypeScript and Python SDKs convert handler
|
|
789
|
+
# failures into JSON-RPC error responses.
|
|
790
|
+
def dispatch_server_request(message)
|
|
791
|
+
handler = @server_request_handlers[message["method"]]
|
|
792
|
+
response = if handler
|
|
575
793
|
begin
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
794
|
+
{
|
|
795
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
796
|
+
id: message["id"],
|
|
797
|
+
result: handler.call(message["params"] || {}),
|
|
798
|
+
}
|
|
799
|
+
rescue ServerRequestError => e
|
|
800
|
+
{
|
|
801
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
802
|
+
id: message["id"],
|
|
803
|
+
error: { code: e.code, message: e.message },
|
|
804
|
+
}
|
|
805
|
+
rescue StandardError => e
|
|
806
|
+
{
|
|
807
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
808
|
+
id: message["id"],
|
|
809
|
+
error: {
|
|
810
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
811
|
+
message: "Internal error handling #{message["method"]} request: #{e.message}",
|
|
812
|
+
},
|
|
813
|
+
}
|
|
580
814
|
end
|
|
815
|
+
else
|
|
816
|
+
{
|
|
817
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
818
|
+
id: message["id"],
|
|
819
|
+
error: {
|
|
820
|
+
code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
|
|
821
|
+
message: "Method not found: #{message["method"]}",
|
|
822
|
+
},
|
|
823
|
+
}
|
|
581
824
|
end
|
|
582
825
|
|
|
583
|
-
|
|
826
|
+
send_client_response(response)
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def send_client_response(response)
|
|
830
|
+
client.post("", response, session_headers)
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def parse_json_buffer(buffer, method, params)
|
|
834
|
+
return if buffer.empty?
|
|
584
835
|
|
|
836
|
+
JSON.parse(buffer)
|
|
837
|
+
rescue JSON::ParserError => e
|
|
585
838
|
raise RequestHandlerError.new(
|
|
586
|
-
"
|
|
839
|
+
"Failed to parse JSON response: #{e.message}",
|
|
587
840
|
{ method: method, params: params },
|
|
588
841
|
error_type: :parse_error,
|
|
589
842
|
)
|
|
590
843
|
end
|
|
844
|
+
|
|
845
|
+
# SEP-1699 resumability: the server closed the SSE stream after a priming event
|
|
846
|
+
# without delivering the response. Treat the graceful close like a network failure:
|
|
847
|
+
# wait the server-specified `retry:` interval (default 1000ms), then reconnect with
|
|
848
|
+
# a GET carrying `Last-Event-ID` so the server can replay the pending response on
|
|
849
|
+
# the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport`
|
|
850
|
+
# reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap).
|
|
851
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
|
|
852
|
+
def await_response_after_disconnect(stream, method, params)
|
|
853
|
+
stream.abortable = true
|
|
854
|
+
|
|
855
|
+
MAX_RECONNECTION_ATTEMPTS.times do
|
|
856
|
+
sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0)
|
|
857
|
+
stream.reset_parser!
|
|
858
|
+
|
|
859
|
+
reconnect_response = begin
|
|
860
|
+
client.get("") do |req|
|
|
861
|
+
req.headers.update(session_headers)
|
|
862
|
+
req.headers["Accept"] = SSE_ACCEPT_HEADER
|
|
863
|
+
req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
|
|
864
|
+
req.options.on_data = stream.on_data
|
|
865
|
+
end
|
|
866
|
+
rescue StreamAbort
|
|
867
|
+
# The awaited response arrived on the reconnected stream.
|
|
868
|
+
nil
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Same fallback as `resolve_response_body` for adapters that do not stream via `on_data`.
|
|
872
|
+
if reconnect_response && stream.response.nil?
|
|
873
|
+
stream.ingest_pending!(reconnect_response.body)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
return stream.response if stream.response
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
raise RequestHandlerError.new(
|
|
880
|
+
"Server closed the SSE stream without a response for #{method} " \
|
|
881
|
+
"after #{MAX_RECONNECTION_ATTEMPTS} reconnection attempts",
|
|
882
|
+
{ method: method, params: params },
|
|
883
|
+
error_type: :internal_error,
|
|
884
|
+
)
|
|
885
|
+
end
|
|
591
886
|
end
|
|
592
887
|
end
|
|
593
888
|
end
|
|
@@ -15,14 +15,26 @@ module MCP
|
|
|
15
15
|
# Required keyword arguments:
|
|
16
16
|
#
|
|
17
17
|
# - `client_id` - String identifying the pre-registered confidential client.
|
|
18
|
-
# - `client_secret` - String shared secret
|
|
19
|
-
#
|
|
18
|
+
# - `client_secret` - String shared secret for the `client_secret_basic` /
|
|
19
|
+
# `client_secret_post` methods. The `client_credentials` grant is for
|
|
20
|
+
# confidential clients, so a credential is mandatory; with
|
|
21
|
+
# `private_key_jwt` the credential is the `private_key` instead.
|
|
20
22
|
#
|
|
21
23
|
# Optional keyword arguments:
|
|
22
24
|
#
|
|
23
|
-
# - `token_endpoint_auth_method` - `"client_secret_basic"` (default)
|
|
24
|
-
# `"client_secret_post"
|
|
25
|
+
# - `token_endpoint_auth_method` - `"client_secret_basic"` (default),
|
|
26
|
+
# `"client_secret_post"`, or `"private_key_jwt"` (RFC 7523 JWT client
|
|
27
|
+
# assertion, per the `io.modelcontextprotocol/oauth-client-credentials`
|
|
28
|
+
# extension / SEP-1046). `"none"` is rejected: an unauthenticated
|
|
25
29
|
# `client_credentials` request is meaningless.
|
|
30
|
+
# - `private_key` - PEM string (or `OpenSSL::PKey::PKey`) used to sign
|
|
31
|
+
# the JWT client assertion. Required with `private_key_jwt`.
|
|
32
|
+
# The key is held on the provider and never written to `storage`.
|
|
33
|
+
# - `signing_algorithm` - `"ES256"` or `"RS256"`. Required with
|
|
34
|
+
# `private_key_jwt`; there is no default, so a mismatch with
|
|
35
|
+
# the server's `token_endpoint_auth_signing_alg_values_supported` fails
|
|
36
|
+
# loudly at construction instead of as a 401 (the TypeScript and Python SDKs
|
|
37
|
+
# also take the algorithm as an explicit option).
|
|
26
38
|
# - `scope` - String of space-separated scopes to request when the server's
|
|
27
39
|
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
|
|
28
40
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
@@ -37,14 +49,16 @@ module MCP
|
|
|
37
49
|
# missing or the requested client authentication method cannot carry them.
|
|
38
50
|
class InvalidCredentialsError < ArgumentError; end
|
|
39
51
|
|
|
40
|
-
SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze
|
|
52
|
+
SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post", "private_key_jwt"].freeze
|
|
41
53
|
|
|
42
54
|
attr_reader :scope, :storage
|
|
43
55
|
|
|
44
56
|
def initialize(
|
|
45
57
|
client_id:,
|
|
46
|
-
client_secret
|
|
58
|
+
client_secret: nil,
|
|
47
59
|
token_endpoint_auth_method: "client_secret_basic",
|
|
60
|
+
private_key: nil,
|
|
61
|
+
signing_algorithm: nil,
|
|
48
62
|
scope: nil,
|
|
49
63
|
storage: nil
|
|
50
64
|
)
|
|
@@ -59,18 +73,37 @@ module MCP
|
|
|
59
73
|
"client_credentials request is not allowed."
|
|
60
74
|
end
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
client_information = { "client_id" => client_id, "token_endpoint_auth_method" => token_endpoint_auth_method }
|
|
77
|
+
|
|
78
|
+
if token_endpoint_auth_method == "private_key_jwt"
|
|
79
|
+
validate_private_key_jwt_arguments!(
|
|
80
|
+
client_secret: client_secret,
|
|
81
|
+
private_key: private_key,
|
|
82
|
+
signing_algorithm: signing_algorithm,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Fail fast on an unparseable key or a key/algorithm mismatch by
|
|
86
|
+
# signing a throwaway assertion now rather than at token time.
|
|
87
|
+
JWTClientAssertion.generate(
|
|
88
|
+
client_id: client_id,
|
|
89
|
+
audience: "urn:mcp:credential-validation",
|
|
90
|
+
private_key: private_key,
|
|
91
|
+
signing_algorithm: signing_algorithm,
|
|
92
|
+
)
|
|
93
|
+
else
|
|
94
|
+
if blank?(client_secret)
|
|
95
|
+
raise InvalidCredentialsError, "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
client_information["client_secret"] = client_secret
|
|
65
99
|
end
|
|
66
100
|
|
|
101
|
+
@client_id = client_id
|
|
102
|
+
@private_key = private_key
|
|
103
|
+
@signing_algorithm = signing_algorithm
|
|
67
104
|
@scope = scope
|
|
68
105
|
@storage = storage || InMemoryStorage.new
|
|
69
|
-
@storage.save_client_information(
|
|
70
|
-
"client_id" => client_id,
|
|
71
|
-
"client_secret" => client_secret,
|
|
72
|
-
"token_endpoint_auth_method" => token_endpoint_auth_method,
|
|
73
|
-
)
|
|
106
|
+
@storage.save_client_information(client_information)
|
|
74
107
|
end
|
|
75
108
|
|
|
76
109
|
# See `Provider#authorization_flow`.
|
|
@@ -78,8 +111,33 @@ module MCP
|
|
|
78
111
|
:client_credentials
|
|
79
112
|
end
|
|
80
113
|
|
|
114
|
+
# Returns a freshly signed RFC 7523 JWT client assertion for the `private_key_jwt` method.
|
|
115
|
+
# `audience` is the authorization server's issuer identifier. Called by `Flow#post_to_token_endpoint`.
|
|
116
|
+
def client_assertion(audience:)
|
|
117
|
+
JWTClientAssertion.generate(
|
|
118
|
+
client_id: @client_id,
|
|
119
|
+
audience: audience,
|
|
120
|
+
private_key: @private_key,
|
|
121
|
+
signing_algorithm: @signing_algorithm,
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
81
125
|
private
|
|
82
126
|
|
|
127
|
+
def validate_private_key_jwt_arguments!(client_secret:, private_key:, signing_algorithm:)
|
|
128
|
+
unless client_secret.nil?
|
|
129
|
+
raise InvalidCredentialsError, "client_secret must not be set with private_key_jwt; the private key is the credential."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if private_key.nil?
|
|
133
|
+
raise InvalidCredentialsError, "private_key is required for the client_credentials grant with private_key_jwt."
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
return unless blank?(signing_algorithm)
|
|
137
|
+
|
|
138
|
+
raise InvalidCredentialsError, "signing_algorithm is required with private_key_jwt (one of #{JWTClientAssertion::SUPPORTED_ALGORITHMS.inspect})."
|
|
139
|
+
end
|
|
140
|
+
|
|
83
141
|
def blank?(value)
|
|
84
142
|
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
85
143
|
end
|