mcp 0.23.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.
@@ -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
- capture_session_info(method, response, body)
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
- 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
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
- parse_sse_response(response.body, method, params)
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
- response.body
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
- 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
-
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
- 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
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
- return json_rpc_response if json_rpc_response
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
- "No valid JSON-RPC response found in SSE stream",
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. 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