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.
@@ -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
- capture_session_info(method, response, body)
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
- def require_event_stream_parser!
540
- require "event_stream_parser"
541
- rescue LoadError
542
- raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
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
- parse_sse_response(response.body, method, params)
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
- response.body
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
- def parse_sse_response(body, method, params)
568
- require_event_stream_parser!
569
-
570
- json_rpc_response = nil
571
- parser = EventStreamParser::Parser.new
572
- parser.feed(body.to_s) do |_type, data, _id|
573
- next if data.empty?
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
- parsed = JSON.parse(data)
577
- json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
578
- rescue JSON::ParserError
579
- next
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
- return json_rpc_response if json_rpc_response
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
- "No valid JSON-RPC response found in SSE stream",
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. The `client_credentials` grant
19
- # is for confidential clients, so a credential is mandatory.
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) or
24
- # `"client_secret_post"`. `"none"` is rejected: an unauthenticated
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
- if blank?(client_secret)
63
- raise InvalidCredentialsError,
64
- "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}."
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