mcp 0.23.0 → 0.25.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 +329 -6
- data/lib/json_rpc_handler.rb +18 -1
- data/lib/mcp/apps.rb +109 -0
- data/lib/mcp/client/elicitation.rb +51 -0
- data/lib/mcp/client/http.rb +383 -32
- data/lib/mcp/client/oauth/client_credentials_provider.rb +72 -14
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +80 -0
- data/lib/mcp/client/oauth/discovery.rb +1 -1
- data/lib/mcp/client/oauth/flow.rb +182 -6
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +101 -0
- 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 +5 -1
- data/lib/mcp/client/paginated_result.rb +7 -5
- data/lib/mcp/client/stdio.rb +3 -1
- data/lib/mcp/client.rb +127 -1
- data/lib/mcp/configuration.rb +1 -1
- data/lib/mcp/error_codes.rb +21 -0
- data/lib/mcp/icon.rb +1 -1
- data/lib/mcp/instrumentation.rb +0 -2
- data/lib/mcp/methods.rb +3 -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/streamable_http_transport.rb +138 -87
- data/lib/mcp/server.rb +192 -23
- data/lib/mcp/server_context.rb +12 -0
- data/lib/mcp/server_session.rb +40 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +10 -3
data/lib/mcp/client/http.rb
CHANGED
|
@@ -8,16 +8,34 @@ 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
|
|
32
|
+
|
|
33
|
+
# Upper bound in bytes on a single JSON-RPC message from the server - an SSE event or
|
|
34
|
+
# a JSON response body - buffered in memory while reading a response. Without a bound,
|
|
35
|
+
# a server that never terminates an SSE event (or never ends a JSON body) grows
|
|
36
|
+
# the buffer indefinitely. Matches the 4 MiB default of `MCP::Client::Stdio::MAX_LINE_BYTES`
|
|
37
|
+
# and the server transports' request cap.
|
|
38
|
+
MAX_MESSAGE_BYTES = 4 * 1024 * 1024
|
|
21
39
|
|
|
22
40
|
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
|
23
41
|
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
|
@@ -54,9 +72,148 @@ module MCP
|
|
|
54
72
|
end
|
|
55
73
|
end
|
|
56
74
|
|
|
75
|
+
# Internal control-flow signal raised inside the streaming callback to stop reading
|
|
76
|
+
# an SSE stream once the awaited JSON-RPC response has arrived; servers may hold
|
|
77
|
+
# a stream open indefinitely (notably the standalone GET stream), so EOF cannot be relied on.
|
|
78
|
+
class StreamAbort < StandardError; end
|
|
79
|
+
private_constant :StreamAbort
|
|
80
|
+
|
|
81
|
+
# Raised inside the streaming callback when the server sends more bytes than
|
|
82
|
+
# `max_message_bytes` without completing a message. Translated into
|
|
83
|
+
# a `RequestHandlerError` (or a listener stop) where the request context is known.
|
|
84
|
+
class MessageTooLargeError < StandardError; end
|
|
85
|
+
private_constant :MessageTooLargeError
|
|
86
|
+
|
|
87
|
+
# Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams:
|
|
88
|
+
# the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
|
|
89
|
+
# and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
|
|
90
|
+
class SSEStream
|
|
91
|
+
attr_reader :buffer, :response, :last_event_id, :retry_ms
|
|
92
|
+
attr_accessor :abortable
|
|
93
|
+
|
|
94
|
+
def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil)
|
|
95
|
+
@abortable = abortable
|
|
96
|
+
@max_message_bytes = max_message_bytes
|
|
97
|
+
@on_request = on_request
|
|
98
|
+
@buffer = +""
|
|
99
|
+
@parser = nil
|
|
100
|
+
@response = nil
|
|
101
|
+
@last_event_id = nil
|
|
102
|
+
@retry_ms = nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Whether the server sent a SEP-1699 priming event (any event carrying an id),
|
|
106
|
+
# which marks the stream as resumable after a graceful close.
|
|
107
|
+
def primed?
|
|
108
|
+
!@last_event_id.nil?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Faraday `on_data` streaming callback. SSE chunks are parsed incrementally;
|
|
112
|
+
# anything else (JSON bodies) accumulates in `buffer`. Both paths are bounded
|
|
113
|
+
# by `max_message_bytes`, checked after the awaited response had its chance to
|
|
114
|
+
# abort the stream so an over-limit tail cannot mask a response that already arrived.
|
|
115
|
+
def on_data
|
|
116
|
+
proc do |chunk, _received_bytes, env|
|
|
117
|
+
if event_stream?(env)
|
|
118
|
+
feed(chunk)
|
|
119
|
+
raise StreamAbort if @abortable && @response
|
|
120
|
+
|
|
121
|
+
if parser_buffered_bytes > @max_message_bytes
|
|
122
|
+
raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing"
|
|
123
|
+
end
|
|
124
|
+
else
|
|
125
|
+
@buffer << chunk
|
|
126
|
+
|
|
127
|
+
if @buffer.bytesize > @max_message_bytes
|
|
128
|
+
raise MessageTooLargeError, "Server response body exceeds #{@max_message_bytes} bytes"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A fresh parser for a new HTTP connection (reconnection GET),
|
|
135
|
+
# so a partial line from the previous stream cannot corrupt the next one.
|
|
136
|
+
def reset_parser!
|
|
137
|
+
@parser = nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Parses an SSE body that was delivered outside the streaming callback:
|
|
141
|
+
# Faraday versions that do not pass `env` to `on_data` leave SSE chunks in `buffer`
|
|
142
|
+
# (consumed here so they are not parsed twice), and adapters without `on_data` support
|
|
143
|
+
# yield the whole body only via `fallback_body` (the Faraday `response.body`).
|
|
144
|
+
def ingest_pending!(fallback_body)
|
|
145
|
+
text = @buffer.empty? ? fallback_body : @buffer.slice!(0..-1)
|
|
146
|
+
feed(text) if text.is_a?(String) && !text.empty?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def event_stream?(env)
|
|
152
|
+
headers = env&.response_headers
|
|
153
|
+
content_type = headers && (headers["content-type"] || headers["Content-Type"])
|
|
154
|
+
!!content_type&.include?("text/event-stream")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def feed(chunk)
|
|
158
|
+
parser.feed(chunk) do |_type, data, id, reconnection_time|
|
|
159
|
+
@last_event_id = id if id && !id.empty?
|
|
160
|
+
@retry_ms = reconnection_time if reconnection_time
|
|
161
|
+
next if data.nil? || data.empty?
|
|
162
|
+
|
|
163
|
+
begin
|
|
164
|
+
parsed = JSON.parse(data)
|
|
165
|
+
rescue JSON::ParserError
|
|
166
|
+
next
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
next unless parsed.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
if parsed.key?("result") || parsed.key?("error")
|
|
172
|
+
@response ||= parsed
|
|
173
|
+
elsif parsed["method"] && parsed.key?("id")
|
|
174
|
+
# A server-to-client request (e.g. `elicitation/create`) delivered
|
|
175
|
+
# on the stream while the original request is still pending.
|
|
176
|
+
@on_request&.call(parsed)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Bytes the parser has buffered for a not-yet-dispatched event: the partial line
|
|
182
|
+
# and the accumulated `data:` field values (plus the small event type and last event id buffers).
|
|
183
|
+
# External byte counting cannot tell consumed-and-discarded bytes (comments, field names, delimiters)
|
|
184
|
+
# from consumed-and-retained ones, so the parser's own String buffers are measured instead;
|
|
185
|
+
# summing every String instance variable keeps the measurement valid if the parser renames or adds buffers.
|
|
186
|
+
# A parser rewrite that buffered elsewhere would make this return 0 and quietly lift the cap -
|
|
187
|
+
# the regression tests feeding an over-limit event guard against that.
|
|
188
|
+
def parser_buffered_bytes
|
|
189
|
+
parser.instance_variables.sum do |name|
|
|
190
|
+
value = parser.instance_variable_get(name)
|
|
191
|
+
value.is_a?(String) ? value.bytesize : 0
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parser
|
|
196
|
+
@parser ||= begin
|
|
197
|
+
require "event_stream_parser"
|
|
198
|
+
EventStreamParser::Parser.new
|
|
199
|
+
rescue LoadError
|
|
200
|
+
raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
|
|
201
|
+
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
|
|
202
|
+
"See https://rubygems.org/gems/event_stream_parser for more details."
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
private_constant :SSEStream
|
|
207
|
+
|
|
57
208
|
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
|
|
58
209
|
|
|
59
|
-
def initialize(url:, headers: {}, oauth: nil, &block)
|
|
210
|
+
def initialize(url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYTES, &block)
|
|
211
|
+
# `nil` or a non-positive value would make the buffering unbounded and silently
|
|
212
|
+
# disable the protection, so reject it up front.
|
|
213
|
+
unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0
|
|
214
|
+
raise ArgumentError, "max_message_bytes must be a positive Integer"
|
|
215
|
+
end
|
|
216
|
+
|
|
60
217
|
if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url)
|
|
61
218
|
# Mask credentials (userinfo) and query parameters before quoting the URL in the error message
|
|
62
219
|
# so they cannot leak into logs.
|
|
@@ -70,6 +227,7 @@ module MCP
|
|
|
70
227
|
@headers = headers
|
|
71
228
|
@faraday_customizer = block
|
|
72
229
|
@oauth = oauth
|
|
230
|
+
@max_message_bytes = max_message_bytes
|
|
73
231
|
# Snapshot the canonical URL at construction time. This single value
|
|
74
232
|
# serves two related roles, both of which need to see the query string:
|
|
75
233
|
#
|
|
@@ -91,6 +249,24 @@ module MCP
|
|
|
91
249
|
@protocol_version = nil
|
|
92
250
|
@server_info = nil
|
|
93
251
|
@connected = false
|
|
252
|
+
@server_request_handlers = {}
|
|
253
|
+
@listener_thread = nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream.
|
|
257
|
+
# The handler receives the request's `params` (a Hash with string keys, possibly empty) and its return value is
|
|
258
|
+
# sent back to the server as the JSON-RPC `result`.
|
|
259
|
+
# The handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code.
|
|
260
|
+
# Requests for methods without a registered handler are answered with a JSON-RPC "method not found" (-32601) error.
|
|
261
|
+
# Registering a handler opens a standalone GET SSE listening stream (once connected), since servers send requests
|
|
262
|
+
# that are not tied to a client request on that stream - matching the TypeScript and Python SDK clients,
|
|
263
|
+
# which start listening after the `initialize` handshake.
|
|
264
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
|
|
265
|
+
def on_server_request(method, &handler)
|
|
266
|
+
raise ArgumentError, "A handler block is required" unless handler
|
|
267
|
+
|
|
268
|
+
@server_request_handlers[method.to_s] = handler
|
|
269
|
+
start_listening if connected?
|
|
94
270
|
end
|
|
95
271
|
|
|
96
272
|
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
@@ -169,6 +345,7 @@ module MCP
|
|
|
169
345
|
end
|
|
170
346
|
|
|
171
347
|
@connected = true
|
|
348
|
+
start_listening if @server_request_handlers.any?
|
|
172
349
|
@server_info
|
|
173
350
|
end
|
|
174
351
|
|
|
@@ -199,13 +376,36 @@ module MCP
|
|
|
199
376
|
step_up_retried = false
|
|
200
377
|
|
|
201
378
|
begin
|
|
379
|
+
# The response is consumed incrementally so that an SSE stream the server holds open
|
|
380
|
+
# (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF
|
|
381
|
+
# so the response object (and its `Mcp-Session-Id` header) is always available for capture.
|
|
382
|
+
stream = SSEStream.new(
|
|
383
|
+
abortable: method.to_s != MCP::Methods::INITIALIZE,
|
|
384
|
+
max_message_bytes: @max_message_bytes,
|
|
385
|
+
on_request: ->(message) { dispatch_server_request(message) },
|
|
386
|
+
)
|
|
387
|
+
|
|
202
388
|
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
389
|
|
|
206
|
-
|
|
390
|
+
response = begin
|
|
391
|
+
client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
|
|
392
|
+
req.options.on_data = stream.on_data
|
|
393
|
+
end
|
|
394
|
+
rescue StreamAbort
|
|
395
|
+
nil
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
body = resolve_response_body(stream, response, method, params)
|
|
399
|
+
|
|
400
|
+
capture_session_info(method, response, body) if response
|
|
207
401
|
|
|
208
402
|
body
|
|
403
|
+
rescue MessageTooLargeError => e
|
|
404
|
+
raise RequestHandlerError.new(
|
|
405
|
+
e.message,
|
|
406
|
+
{ method: method, params: params },
|
|
407
|
+
error_type: :internal_error,
|
|
408
|
+
)
|
|
209
409
|
rescue Faraday::BadRequestError => e
|
|
210
410
|
raise RequestHandlerError.new(
|
|
211
411
|
"The #{method} request is invalid",
|
|
@@ -522,12 +722,68 @@ module MCP
|
|
|
522
722
|
end
|
|
523
723
|
|
|
524
724
|
def clear_session
|
|
725
|
+
stop_listening
|
|
525
726
|
@session_id = nil
|
|
526
727
|
@protocol_version = nil
|
|
527
728
|
@server_info = nil
|
|
528
729
|
@connected = false
|
|
529
730
|
end
|
|
530
731
|
|
|
732
|
+
# Opens the standalone GET SSE listening stream on a background thread so server-to-client requests
|
|
733
|
+
# can arrive while the main thread is blocked on its own request (e.g. a tools/call awaiting elicitation).
|
|
734
|
+
# Like the TypeScript and Python SDK clients, the GET is fired without waiting for it to be established.
|
|
735
|
+
def start_listening
|
|
736
|
+
return if @listener_thread&.alive?
|
|
737
|
+
|
|
738
|
+
@listener_thread = Thread.new { listen_for_server_requests }
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def stop_listening
|
|
742
|
+
@listener_thread&.kill
|
|
743
|
+
@listener_thread = nil
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# Reconnection semantics match the TypeScript and Python SDK clients: a stream that was established and
|
|
747
|
+
# then closed gracefully is retried indefinitely after the server's `retry:` interval (SSE semantics),
|
|
748
|
+
# while consecutive connection failures - HTTP errors and idle read timeouts both count - stop the listener
|
|
749
|
+
# once they reach `MAX_RECONNECTION_ATTEMPTS`; a successful stream in between resets the count.
|
|
750
|
+
# A 405 stops immediately without retrying, since it is the spec's answer from a server that offers no listening stream.
|
|
751
|
+
def listen_for_server_requests
|
|
752
|
+
stream = SSEStream.new(
|
|
753
|
+
abortable: false,
|
|
754
|
+
max_message_bytes: @max_message_bytes,
|
|
755
|
+
on_request: ->(message) { dispatch_server_request(message) },
|
|
756
|
+
)
|
|
757
|
+
consecutive_failures = 0
|
|
758
|
+
|
|
759
|
+
loop do
|
|
760
|
+
begin
|
|
761
|
+
client.get("") do |request|
|
|
762
|
+
request.headers.update(session_headers)
|
|
763
|
+
request.headers["Accept"] = SSE_ACCEPT_HEADER
|
|
764
|
+
request.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
|
|
765
|
+
request.options.read_timeout = SSE_LISTENER_READ_TIMEOUT
|
|
766
|
+
request.options.on_data = stream.on_data
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
consecutive_failures = 0
|
|
770
|
+
rescue MessageTooLargeError
|
|
771
|
+
# Reconnecting would let the server stream the same over-limit event again, so stop listening instead of retrying.
|
|
772
|
+
break
|
|
773
|
+
rescue Faraday::Error => e
|
|
774
|
+
break if e.response&.dig(:status) == 405
|
|
775
|
+
|
|
776
|
+
consecutive_failures += 1
|
|
777
|
+
|
|
778
|
+
break if consecutive_failures >= MAX_RECONNECTION_ATTEMPTS
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
stream.reset_parser!
|
|
782
|
+
|
|
783
|
+
sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0)
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
|
|
531
787
|
def require_faraday!
|
|
532
788
|
require "faraday"
|
|
533
789
|
rescue LoadError
|
|
@@ -536,15 +792,11 @@ module MCP
|
|
|
536
792
|
"See https://rubygems.org/gems/faraday for more details."
|
|
537
793
|
end
|
|
538
794
|
|
|
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
|
|
795
|
+
# Determines the logical JSON-RPC body of the exchange after the POST stream finished
|
|
796
|
+
# (or was aborted because the response already arrived).
|
|
797
|
+
def resolve_response_body(stream, response, method, params)
|
|
798
|
+
return stream.response if stream.response
|
|
546
799
|
|
|
547
|
-
def parse_response_body(response, method, params)
|
|
548
800
|
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
|
|
549
801
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
550
802
|
return if response.status == 202
|
|
@@ -552,9 +804,29 @@ module MCP
|
|
|
552
804
|
content_type = response.headers["Content-Type"]
|
|
553
805
|
|
|
554
806
|
if content_type&.include?("text/event-stream")
|
|
555
|
-
|
|
807
|
+
# Nothing was parsed during streaming: either the adapter does not support `on_data`
|
|
808
|
+
# (the whole body sits in `response.body`) or Faraday did not pass `env` to `on_data`
|
|
809
|
+
# (Faraday < 2.1; the SSE chunks sit in `buffer`). Parse whichever holds the body.
|
|
810
|
+
unless stream.primed?
|
|
811
|
+
stream.ingest_pending!(response.body)
|
|
812
|
+
return stream.response if stream.response
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# SEP-1699: a graceful close after a priming event (an event with an id) but before
|
|
816
|
+
# the response means "reconnect via GET to resume".
|
|
817
|
+
return await_response_after_disconnect(stream, method, params) if stream.primed?
|
|
818
|
+
|
|
819
|
+
raise RequestHandlerError.new(
|
|
820
|
+
"No valid JSON-RPC response found in SSE stream",
|
|
821
|
+
{ method: method, params: params },
|
|
822
|
+
error_type: :parse_error,
|
|
823
|
+
)
|
|
556
824
|
elsif content_type&.include?("application/json")
|
|
557
|
-
|
|
825
|
+
return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty?
|
|
826
|
+
|
|
827
|
+
# Adapters without `on_data` support deliver the body via `response.body`,
|
|
828
|
+
# already parsed by the json response middleware.
|
|
829
|
+
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
|
|
558
830
|
else
|
|
559
831
|
raise RequestHandlerError.new(
|
|
560
832
|
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
|
|
@@ -564,30 +836,109 @@ module MCP
|
|
|
564
836
|
end
|
|
565
837
|
end
|
|
566
838
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
839
|
+
# Answers a server-to-client request received on an SSE stream by invoking the handler registered via
|
|
840
|
+
# `on_server_request` and POSTing its return value back as a JSON-RPC response; the server ACKs with
|
|
841
|
+
# 202 Accepted. Requests without a registered handler are answered with a "method not found" error.
|
|
842
|
+
# A handler may raise `ServerRequestError` to answer with a specific JSON-RPC error code
|
|
843
|
+
# (e.g. `-1` for a rejected sampling request); any other error is answered with an "internal error".
|
|
844
|
+
# In every case the server is not left waiting, matching how the TypeScript and Python SDKs convert handler
|
|
845
|
+
# failures into JSON-RPC error responses.
|
|
846
|
+
def dispatch_server_request(message)
|
|
847
|
+
handler = @server_request_handlers[message["method"]]
|
|
848
|
+
response = if handler
|
|
575
849
|
begin
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
850
|
+
{
|
|
851
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
852
|
+
id: message["id"],
|
|
853
|
+
result: handler.call(message["params"] || {}),
|
|
854
|
+
}
|
|
855
|
+
rescue ServerRequestError => e
|
|
856
|
+
{
|
|
857
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
858
|
+
id: message["id"],
|
|
859
|
+
error: { code: e.code, message: e.message },
|
|
860
|
+
}
|
|
861
|
+
rescue StandardError => e
|
|
862
|
+
{
|
|
863
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
864
|
+
id: message["id"],
|
|
865
|
+
error: {
|
|
866
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
867
|
+
message: "Internal error handling #{message["method"]} request: #{e.message}",
|
|
868
|
+
},
|
|
869
|
+
}
|
|
580
870
|
end
|
|
871
|
+
else
|
|
872
|
+
{
|
|
873
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
874
|
+
id: message["id"],
|
|
875
|
+
error: {
|
|
876
|
+
code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
|
|
877
|
+
message: "Method not found: #{message["method"]}",
|
|
878
|
+
},
|
|
879
|
+
}
|
|
581
880
|
end
|
|
582
881
|
|
|
583
|
-
|
|
882
|
+
send_client_response(response)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def send_client_response(response)
|
|
886
|
+
client.post("", response, session_headers)
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def parse_json_buffer(buffer, method, params)
|
|
890
|
+
return if buffer.empty?
|
|
584
891
|
|
|
892
|
+
JSON.parse(buffer)
|
|
893
|
+
rescue JSON::ParserError => e
|
|
585
894
|
raise RequestHandlerError.new(
|
|
586
|
-
"
|
|
895
|
+
"Failed to parse JSON response: #{e.message}",
|
|
587
896
|
{ method: method, params: params },
|
|
588
897
|
error_type: :parse_error,
|
|
589
898
|
)
|
|
590
899
|
end
|
|
900
|
+
|
|
901
|
+
# SEP-1699 resumability: the server closed the SSE stream after a priming event
|
|
902
|
+
# without delivering the response. Treat the graceful close like a network failure:
|
|
903
|
+
# wait the server-specified `retry:` interval (default 1000ms), then reconnect with
|
|
904
|
+
# a GET carrying `Last-Event-ID` so the server can replay the pending response on
|
|
905
|
+
# the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport`
|
|
906
|
+
# reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap).
|
|
907
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
|
|
908
|
+
def await_response_after_disconnect(stream, method, params)
|
|
909
|
+
stream.abortable = true
|
|
910
|
+
|
|
911
|
+
MAX_RECONNECTION_ATTEMPTS.times do
|
|
912
|
+
sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0)
|
|
913
|
+
stream.reset_parser!
|
|
914
|
+
|
|
915
|
+
reconnect_response = begin
|
|
916
|
+
client.get("") do |req|
|
|
917
|
+
req.headers.update(session_headers)
|
|
918
|
+
req.headers["Accept"] = SSE_ACCEPT_HEADER
|
|
919
|
+
req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
|
|
920
|
+
req.options.on_data = stream.on_data
|
|
921
|
+
end
|
|
922
|
+
rescue StreamAbort
|
|
923
|
+
# The awaited response arrived on the reconnected stream.
|
|
924
|
+
nil
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# Same fallback as `resolve_response_body` for adapters that do not stream via `on_data`.
|
|
928
|
+
if reconnect_response && stream.response.nil?
|
|
929
|
+
stream.ingest_pending!(reconnect_response.body)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
return stream.response if stream.response
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
raise RequestHandlerError.new(
|
|
936
|
+
"Server closed the SSE stream without a response for #{method} " \
|
|
937
|
+
"after #{MAX_RECONNECTION_ATTEMPTS} reconnection attempts",
|
|
938
|
+
{ method: method, params: params },
|
|
939
|
+
error_type: :internal_error,
|
|
940
|
+
)
|
|
941
|
+
end
|
|
591
942
|
end
|
|
592
943
|
end
|
|
593
944
|
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
|