mcp 0.24.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4773bb2a9b8bce187692737d82d8ba204da880a68255258bc1bc697ee994d356
4
- data.tar.gz: e27c758cacba095342a7b8a7b1feb7299298773d0399676f5ee3827322765fd5
3
+ metadata.gz: 829c2bc3c3644f2a7edcf5fd44b96d87887c37da3a4cf4766eed79a2676425ee
4
+ data.tar.gz: 1a168f15a16ad704db47e8c8c2aa7be3f4f869af2a5199fa54d44b4a05990aa0
5
5
  SHA512:
6
- metadata.gz: 83c6b8ab2f63916f03f6d79a5e0f8e572f828d5153f0ee3dcb43e002c97b46a9e686959dd0e8e46a620b5155b456739a4ffd78dc51093898c56ddf2c6a21f99c
7
- data.tar.gz: 46687cedce187961437298f5d9d4a807e058099a8c8e4f7793473999eb79a4dc70cf3a9cceaad8a3c8df4c20e4bebc85e7995844f413e64c6d4109918ffa856f
6
+ metadata.gz: e6ca51112faf60a4a2b9f5be2db20f9fe25b6581ca327708f483c03f021b9a68ce8df8c3342e5430132ac670103a26d4ac2f9617e3e0358e074745d6fa4c7743
7
+ data.tar.gz: a57eea28ea7e173422e855702789780381032e888bed42eb42900251e05f8a2806df4220c50c0727423d6c5a8e9124127cdc53e9e9bf34170e6a7c2afa058e7a
data/README.md CHANGED
@@ -2318,6 +2318,10 @@ response = client.call_tool(
2318
2318
 
2319
2319
  The server will send `notifications/progress` back to the client during execution.
2320
2320
 
2321
+ `MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server -
2322
+ an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from
2323
+ a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses.
2324
+
2321
2325
  #### Server-to-Client Requests (Elicitation)
2322
2326
 
2323
2327
  Servers can send requests back to the client while one of the client's own requests is in flight - for example,
@@ -2342,6 +2346,44 @@ since servers deliver requests that are not tied to a client request on that str
2342
2346
  a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
2343
2347
  `http_transport.on_server_request("method/name") { |params| ... }`.
2344
2348
 
2349
+ #### Server-to-Client Requests (Sampling)
2350
+
2351
+ Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling),
2352
+ letting a server leverage the client's model access without its own API keys.
2353
+
2354
+ > MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`.
2355
+ > Register this handler to interoperate with servers that still send sampling requests during the deprecation window;
2356
+ > new servers should call LLM provider APIs directly.
2357
+
2358
+ Register a handler and advertise the capability on `connect`:
2359
+
2360
+ ```ruby
2361
+ client.connect(capabilities: { sampling: {} })
2362
+
2363
+ client.on_sampling do |params|
2364
+ completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
2365
+ {
2366
+ role: "assistant",
2367
+ content: { type: "text", text: completion.text },
2368
+ model: completion.model,
2369
+ stopReason: "endTurn",
2370
+ }
2371
+ end
2372
+ ```
2373
+
2374
+ For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response.
2375
+ To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`:
2376
+
2377
+ ```ruby
2378
+ client.on_sampling do |params|
2379
+ raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
2380
+
2381
+ generate_completion(params)
2382
+ end
2383
+ ```
2384
+
2385
+ Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
2386
+
2345
2387
  #### HTTP Authorization
2346
2388
 
2347
2389
  By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:
@@ -2505,6 +2547,43 @@ Keyword arguments:
2505
2547
  - `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
2506
2548
  - `scope`, `storage`: Optional, same meaning as on `Provider`.
2507
2549
 
2550
+ ##### Cross-App Access (JWT Bearer) Grant
2551
+
2552
+ For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`.
2553
+ The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG
2554
+ to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`.
2555
+ Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK.
2556
+
2557
+ `MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into
2558
+ an enterprise secret store or a test double without changing the transport wiring.
2559
+
2560
+ ```ruby
2561
+ provider = MCP::Client::OAuth::CrossAppAccessProvider.new(
2562
+ client_id: "my-mcp-client",
2563
+ client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
2564
+ assertion_provider: ->(audience:, resource:) {
2565
+ MCP::Client::OAuth::IDJAGTokenExchange.request(
2566
+ token_endpoint: "https://idp.example.com/token",
2567
+ id_token: ENV.fetch("IDP_ID_TOKEN"),
2568
+ client_id: "my-idp-client",
2569
+ audience: audience,
2570
+ resource: resource,
2571
+ )
2572
+ },
2573
+ # scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one)
2574
+ )
2575
+
2576
+ transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
2577
+ ```
2578
+
2579
+ Keyword arguments:
2580
+
2581
+ - `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server.
2582
+ - `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
2583
+ `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
2584
+ Passing both through to `IDJAGTokenExchange.request` covers the common case.
2585
+ - `scope`, `storage`: Optional, same meaning as on `Provider`.
2586
+
2508
2587
  ##### Communication Security
2509
2588
 
2510
2589
  When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
@@ -16,7 +16,7 @@ module JsonRpcHandler
16
16
  PARSE_ERROR = -32700
17
17
  end
18
18
 
19
- DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
19
+ DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/.freeze
20
20
 
21
21
  # Sentinel return value from a handler. When a handler returns this,
22
22
  # `process_request` emits no JSON-RPC response for the request,
@@ -30,6 +30,13 @@ module MCP
30
30
  # (60 seconds for Net::HTTP) would recycle quiet streams too eagerly.
31
31
  SSE_LISTENER_READ_TIMEOUT = 300
32
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
39
+
33
40
  # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
34
41
  # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
35
42
  # is trivially observed and stolen.
@@ -71,6 +78,12 @@ module MCP
71
78
  class StreamAbort < StandardError; end
72
79
  private_constant :StreamAbort
73
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
+
74
87
  # Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams:
75
88
  # the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
76
89
  # and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
@@ -78,8 +91,9 @@ module MCP
78
91
  attr_reader :buffer, :response, :last_event_id, :retry_ms
79
92
  attr_accessor :abortable
80
93
 
81
- def initialize(abortable:, on_request: nil)
94
+ def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil)
82
95
  @abortable = abortable
96
+ @max_message_bytes = max_message_bytes
83
97
  @on_request = on_request
84
98
  @buffer = +""
85
99
  @parser = nil
@@ -95,14 +109,24 @@ module MCP
95
109
  end
96
110
 
97
111
  # Faraday `on_data` streaming callback. SSE chunks are parsed incrementally;
98
- # anything else (JSON bodies) accumulates in `buffer`.
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.
99
115
  def on_data
100
116
  proc do |chunk, _received_bytes, env|
101
117
  if event_stream?(env)
102
118
  feed(chunk)
103
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
104
124
  else
105
125
  @buffer << chunk
126
+
127
+ if @buffer.bytesize > @max_message_bytes
128
+ raise MessageTooLargeError, "Server response body exceeds #{@max_message_bytes} bytes"
129
+ end
106
130
  end
107
131
  end
108
132
  end
@@ -154,6 +178,20 @@ module MCP
154
178
  end
155
179
  end
156
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
+
157
195
  def parser
158
196
  @parser ||= begin
159
197
  require "event_stream_parser"
@@ -169,7 +207,13 @@ module MCP
169
207
 
170
208
  attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
171
209
 
172
- 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
+
173
217
  if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url)
174
218
  # Mask credentials (userinfo) and query parameters before quoting the URL in the error message
175
219
  # so they cannot leak into logs.
@@ -183,6 +227,7 @@ module MCP
183
227
  @headers = headers
184
228
  @faraday_customizer = block
185
229
  @oauth = oauth
230
+ @max_message_bytes = max_message_bytes
186
231
  # Snapshot the canonical URL at construction time. This single value
187
232
  # serves two related roles, both of which need to see the query string:
188
233
  #
@@ -336,6 +381,7 @@ module MCP
336
381
  # so the response object (and its `Mcp-Session-Id` header) is always available for capture.
337
382
  stream = SSEStream.new(
338
383
  abortable: method.to_s != MCP::Methods::INITIALIZE,
384
+ max_message_bytes: @max_message_bytes,
339
385
  on_request: ->(message) { dispatch_server_request(message) },
340
386
  )
341
387
 
@@ -354,6 +400,12 @@ module MCP
354
400
  capture_session_info(method, response, body) if response
355
401
 
356
402
  body
403
+ rescue MessageTooLargeError => e
404
+ raise RequestHandlerError.new(
405
+ e.message,
406
+ { method: method, params: params },
407
+ error_type: :internal_error,
408
+ )
357
409
  rescue Faraday::BadRequestError => e
358
410
  raise RequestHandlerError.new(
359
411
  "The #{method} request is invalid",
@@ -699,6 +751,7 @@ module MCP
699
751
  def listen_for_server_requests
700
752
  stream = SSEStream.new(
701
753
  abortable: false,
754
+ max_message_bytes: @max_message_bytes,
702
755
  on_request: ->(message) { dispatch_server_request(message) },
703
756
  )
704
757
  consecutive_failures = 0
@@ -714,6 +767,9 @@ module MCP
714
767
  end
715
768
 
716
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
717
773
  rescue Faraday::Error => e
718
774
  break if e.response&.dig(:status) == 405
719
775
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # OAuth client configuration for the MCP Enterprise Managed Authorization extension (SEP-990, "Cross-App Access"):
7
+ # the client obtains an Identity Assertion Authorization Grant (ID-JAG) from an enterprise identity provider and
8
+ # presents it to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with
9
+ # `client_secret_basic`. Handed to `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
10
+ #
11
+ # Mirrors `CrossAppAccessProvider` in the TypeScript SDK: the assertion is supplied by a callable so it can come
12
+ # from `IDJAGTokenExchange` (the common case), an enterprise secret store, or a test double.
13
+ #
14
+ # Required keyword arguments:
15
+ #
16
+ # - `client_id` - String identifying the pre-registered confidential client at the MCP authorization server.
17
+ # - `client_secret` - String shared secret for `client_secret_basic`.
18
+ # - `assertion_provider` - Callable invoked as `call(audience:, resource:)`, returning the ID-JAG assertion to
19
+ # present. `audience` is the authorization server's issuer identifier and `resource` is the canonical MCP server URL;
20
+ # pass both through to `IDJAGTokenExchange.request` when exchanging an IdP ID token.
21
+ #
22
+ # Optional keyword arguments:
23
+ #
24
+ # - `scope` - String of space-separated scopes to request when the server's `WWW-Authenticate` and
25
+ # the Protected Resource Metadata do not specify one.
26
+ # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
27
+ # Defaults to an `InMemoryStorage`.
28
+ #
29
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
30
+ class CrossAppAccessProvider
31
+ include StorageBackedProvider
32
+
33
+ # Raised when the provider is constructed without the pieces the `jwt-bearer` grant needs.
34
+ class InvalidConfigurationError < ArgumentError; end
35
+
36
+ attr_reader :scope, :storage
37
+
38
+ def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil)
39
+ if blank?(client_id)
40
+ raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
41
+ end
42
+
43
+ if blank?(client_secret)
44
+ raise InvalidConfigurationError, "client_secret is required: SEP-990 authenticates the jwt-bearer grant with client_secret_basic."
45
+ end
46
+
47
+ unless assertion_provider.respond_to?(:call)
48
+ raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
49
+ end
50
+
51
+ @assertion_provider = assertion_provider
52
+ @scope = scope
53
+ @storage = storage || InMemoryStorage.new
54
+ @storage.save_client_information(
55
+ "client_id" => client_id,
56
+ "client_secret" => client_secret,
57
+ "token_endpoint_auth_method" => "client_secret_basic",
58
+ )
59
+ end
60
+
61
+ # See `Provider#authorization_flow`.
62
+ def authorization_flow
63
+ :jwt_bearer
64
+ end
65
+
66
+ # Returns the ID-JAG assertion to present at the MCP authorization server. Called by `Flow#run_jwt_bearer!` with the audience
67
+ # and resource resolved during discovery.
68
+ def jwt_bearer_assertion(audience:, resource:)
69
+ @assertion_provider.call(audience: audience, resource: resource)
70
+ end
71
+
72
+ private
73
+
74
+ def blank?(value)
75
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -37,7 +37,7 @@ module MCP
37
37
  # Matches a single `key=value` pair inside an HTTP auth-scheme challenge.
38
38
  # `value` is either a quoted string (which can contain commas and spaces)
39
39
  # or a bare token, per RFC 7235.
40
- WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/
40
+ WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
41
41
 
42
42
  class << self
43
43
  # Parses a `WWW-Authenticate` header and returns the parameters of
@@ -51,8 +51,11 @@ module MCP
51
51
 
52
52
  as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
53
53
 
54
- if provider_authorization_flow == :client_credentials
54
+ case provider_authorization_flow
55
+ when :client_credentials
55
56
  return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
57
+ when :jwt_bearer
58
+ return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
56
59
  end
57
60
 
58
61
  ensure_pkce_supported!(as_metadata)
@@ -154,6 +157,36 @@ module MCP
154
157
  "refusing to send them to the current authorization server (SEP-2352)."
155
158
  end
156
159
 
160
+ # Runs the RFC 7523 `jwt-bearer` grant for the SEP-990 Enterprise Managed Authorization extension:
161
+ # the provider supplies an ID-JAG assertion (typically obtained from an enterprise IdP via `IDJAGTokenExchange`),
162
+ # which is presented at the token endpoint with `client_secret_basic` authentication. Shares the same discovery
163
+ # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
164
+ # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
165
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
166
+ def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
167
+ client_info = @provider.client_information
168
+ unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
169
+ raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
170
+ end
171
+
172
+ assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
173
+ if assertion.nil? || assertion.to_s.empty?
174
+ raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
175
+ end
176
+
177
+ form = {
178
+ "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
179
+ "assertion" => assertion,
180
+ }
181
+ effective_scope = resolve_scope(scope: scope, prm: prm)
182
+ form["scope"] = effective_scope if effective_scope
183
+ form["resource"] = resource if resource
184
+
185
+ tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
186
+ @provider.save_tokens(tokens)
187
+ :authorized
188
+ end
189
+
157
190
  # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
158
191
  # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
159
192
  # checks before talking to it.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module MCP
7
+ class Client
8
+ module OAuth
9
+ # RFC 8693 token exchange against an enterprise identity provider, turning an IdP-issued ID token into
10
+ # an Identity Assertion Authorization Grant (ID-JAG) per the MCP Enterprise Managed Authorization extension (SEP-990).
11
+ # The returned ID-JAG is an opaque assertion the client then presents to the MCP authorization server with
12
+ # the RFC 7523 `jwt-bearer` grant (see `CrossAppAccessProvider`).
13
+ # Mirrors `requestJwtAuthorizationGrant` in the TypeScript SDK.
14
+ #
15
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
16
+ # - https://www.rfc-editor.org/rfc/rfc8693
17
+ module IDJAGTokenExchange
18
+ # Raised when the identity provider's token exchange fails or returns something other than an ID-JAG.
19
+ class ExchangeError < StandardError; end
20
+
21
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
22
+ ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
23
+ ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"
24
+
25
+ class << self
26
+ # Exchanges `id_token` for an ID-JAG at the IdP's token endpoint and returns the assertion string.
27
+ #
28
+ # @param token_endpoint [String] The identity provider's token endpoint.
29
+ # @param id_token [String] The IdP-issued ID token (the subject token).
30
+ # @param client_id [String] The client's identifier at the IdP.
31
+ # @param audience [String] The MCP authorization server's issuer identifier.
32
+ # @param resource [String] The canonical MCP server URL (RFC 8707).
33
+ # @param http_client [Object, nil] Faraday-compatible client; built lazily by default.
34
+ def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
35
+ http_client ||= default_http_client
36
+
37
+ response = begin
38
+ http_client.post(token_endpoint) do |req|
39
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
40
+ req.headers["Accept"] = "application/json"
41
+ req.body = URI.encode_www_form(
42
+ "grant_type" => GRANT_TYPE,
43
+ "subject_token" => id_token,
44
+ "subject_token_type" => ID_TOKEN_TYPE,
45
+ "requested_token_type" => ID_JAG_TOKEN_TYPE,
46
+ "audience" => audience,
47
+ "resource" => resource,
48
+ "client_id" => client_id,
49
+ )
50
+ end
51
+ rescue Faraday::Error => e
52
+ raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
53
+ end
54
+
55
+ if response.status < 200 || response.status >= 300
56
+ raise ExchangeError, "Identity provider token exchange returned status #{response.status}."
57
+ end
58
+
59
+ parse_id_jag(response)
60
+ end
61
+
62
+ private
63
+
64
+ def parse_id_jag(response)
65
+ body = response.body.is_a?(String) ? response.body : response.body.to_s
66
+ parsed = begin
67
+ JSON.parse(body)
68
+ rescue JSON::ParserError => e
69
+ raise ExchangeError, "Failed to parse token exchange response: #{e.message}."
70
+ end
71
+
72
+ unless parsed.is_a?(Hash)
73
+ raise ExchangeError, "Token exchange response is not a JSON object (got #{parsed.class})."
74
+ end
75
+
76
+ issued_token_type = parsed["issued_token_type"]
77
+ unless issued_token_type == ID_JAG_TOKEN_TYPE
78
+ raise ExchangeError,
79
+ "Token exchange did not issue an ID-JAG " \
80
+ "(expected issued_token_type #{ID_JAG_TOKEN_TYPE.inspect}, got #{issued_token_type.inspect})."
81
+ end
82
+
83
+ assertion = parsed["access_token"]
84
+ if assertion.nil? || assertion.to_s.empty?
85
+ raise ExchangeError, "Token exchange response is missing `access_token`."
86
+ end
87
+
88
+ assertion
89
+ end
90
+
91
+ def default_http_client
92
+ require "faraday"
93
+ Faraday.new do |faraday|
94
+ faraday.headers["Accept"] = "application/json"
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -8,12 +8,15 @@ require_relative "oauth/storage_backed_provider"
8
8
  require_relative "oauth/jwt_client_assertion"
9
9
  require_relative "oauth/provider"
10
10
  require_relative "oauth/client_credentials_provider"
11
+ require_relative "oauth/id_jag_token_exchange"
12
+ require_relative "oauth/cross_app_access_provider"
11
13
 
12
14
  module MCP
13
15
  class Client
14
16
  # OAuth client support for the MCP Authorization spec (PRM discovery,
15
17
  # Authorization Server metadata discovery, Dynamic Client Registration,
16
- # OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
18
+ # OAuth 2.1 Authorization Code + PKCE, the client_credentials grant,
19
+ # and the SEP-990 Enterprise Managed Authorization jwt-bearer grant).
17
20
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
18
21
  module OAuth
19
22
  end
data/lib/mcp/client.rb CHANGED
@@ -423,6 +423,44 @@ module MCP
423
423
  transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
424
424
  end
425
425
 
426
+ # Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight.
427
+ # The handler receives the request `params` (`messages`, `maxTokens`, optionally `systemPrompt`, `modelPreferences`, `tools`,
428
+ # `toolChoice`, ...; string keys) and must return a `CreateMessageResult`-shaped Hash:
429
+ # `{ role: "assistant", content: { type: "text", text: "..." }, model: "...", stopReason: "..." }`.
430
+ #
431
+ # For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response
432
+ # before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`.
433
+ #
434
+ # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to
435
+ # `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them.
436
+ #
437
+ # @example Forward the request to an LLM and return its completion
438
+ #
439
+ # client.on_sampling do |params|
440
+ # raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
441
+ #
442
+ # completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
443
+ # {
444
+ # role: "assistant",
445
+ # content: { type: "text", text: completion.text },
446
+ # model: completion.model,
447
+ # stopReason: "endTurn",
448
+ # }
449
+ # end
450
+ #
451
+ # https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
452
+ #
453
+ # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
454
+ # Register this handler only to interoperate with servers that still send sampling requests during the deprecation window;
455
+ # new servers should call LLM provider APIs directly.
456
+ def on_sampling(&handler)
457
+ unless transport.respond_to?(:on_server_request)
458
+ raise ArgumentError, "The transport does not support server-to-client requests"
459
+ end
460
+
461
+ transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler)
462
+ end
463
+
426
464
  # Sends a `ping` request to the server to verify the connection is alive.
427
465
  # Per the MCP spec, the server responds with an empty result.
428
466
  #
@@ -5,7 +5,7 @@ module MCP
5
5
  LATEST_STABLE_PROTOCOL_VERSION = "2025-11-25"
6
6
  SUPPORTED_STABLE_PROTOCOL_VERSIONS = [
7
7
  LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05",
8
- ]
8
+ ].freeze
9
9
  DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
10
10
 
11
11
  attr_writer :exception_reporter, :around_request
data/lib/mcp/icon.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module MCP
4
4
  class Icon
5
- SUPPORTED_THEMES = ["light", "dark"]
5
+ SUPPORTED_THEMES = ["light", "dark"].freeze
6
6
 
7
7
  attr_reader :mime_type, :sizes, :src, :theme
8
8
 
@@ -14,9 +14,7 @@ module MCP
14
14
  rescue => e
15
15
  already_reported = begin
16
16
  !!exception_already_reported&.call(e)
17
- # rubocop:disable Lint/RescueException
18
17
  rescue Exception
19
- # rubocop:enable Lint/RescueException
20
18
  # The predicate is expected to be side-effect-free and return a boolean.
21
19
  # Any exception raised from it (including non-StandardError such as SystemExit)
22
20
  # must not shadow the original exception.
@@ -8,7 +8,7 @@ module MCP
8
8
  # Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`.
9
9
  # Variable names are restricted to valid Regexp named-group names,
10
10
  # so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match.
11
- VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/
11
+ VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/.freeze
12
12
 
13
13
  attr_reader :uri_template_value
14
14
  attr_reader :title_value
@@ -19,9 +19,9 @@ module MCP
19
19
  class InvalidJsonError < StandardError; end
20
20
 
21
21
  SSE_HEADERS = {
22
- "Content-Type" => "text/event-stream",
23
- "Cache-Control" => "no-cache",
24
- "Connection" => "keep-alive",
22
+ "content-type" => "text/event-stream",
23
+ "cache-control" => "no-cache",
24
+ "connection" => "keep-alive",
25
25
  }.freeze
26
26
 
27
27
  # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
@@ -517,6 +517,11 @@ module MCP
517
517
  end
518
518
 
519
519
  if notification?(body)
520
+ # Reject a notification carrying an unknown or expired session ID instead of
521
+ # dispatching it against the shared `Server`. Mirrors the response and regular-request
522
+ # branches; without it a custom notification handler could run without a live session.
523
+ return session_not_found_response if !@stateless && !session_active?(session_id)
524
+
520
525
  dispatch_notification(body_string, session_id)
521
526
  handle_accepted
522
527
  elsif response?(body)
@@ -561,7 +566,7 @@ module MCP
561
566
  end
562
567
 
563
568
  def handle_delete(request)
564
- success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
569
+ success_response = [200, { "content-type" => "application/json" }, [{ success: true }.to_json]]
565
570
 
566
571
  if @stateless
567
572
  protocol_version_error = validate_protocol_version_header(request)
@@ -757,7 +762,7 @@ module MCP
757
762
 
758
763
  def json_rpc_error_response(status:, code:, message:)
759
764
  body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
760
- [status, { "Content-Type" => "application/json" }, [body.to_json]]
765
+ [status, { "content-type" => "application/json" }, [body.to_json]]
761
766
  end
762
767
 
763
768
  def notification?(body)
@@ -844,6 +849,17 @@ module MCP
844
849
 
845
850
  response = server_session.handle_json(body_string)
846
851
 
852
+ # `initialize_request?` matches on the method alone, so an `initialize` sent without
853
+ # an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
854
+ # but JSON-RPC emits no response for an id-less request, so `response` is nil.
855
+ # Returning `[200, ..., [nil]]` would place nil in the Rack body (which the web server cannot serialize),
856
+ # and the session would be retained since it is marked initialized. Discard the orphaned session
857
+ # and ack with 202, mirroring the nil-response handling in a regular request.
858
+ if response.nil?
859
+ cleanup_session(session_id) if session_id
860
+ return handle_accepted
861
+ end
862
+
847
863
  # If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
848
864
  # `mark_initialized!` was never called. Discard the orphaned session and omit
849
865
  # the `Mcp-Session-Id` header so the client retries from a clean state instead of
@@ -854,10 +870,10 @@ module MCP
854
870
  end
855
871
 
856
872
  headers = {
857
- "Content-Type" => "application/json",
873
+ "content-type" => "application/json",
858
874
  }
859
875
 
860
- headers["Mcp-Session-Id"] = session_id if session_id
876
+ headers["mcp-session-id"] = session_id if session_id
861
877
 
862
878
  [200, headers, [response]]
863
879
  end
@@ -899,7 +915,7 @@ module MCP
899
915
  # which would produce an empty body the client cannot parse as JSON.
900
916
  return handle_accepted if response.nil?
901
917
 
902
- [200, { "Content-Type" => "application/json" }, [response]]
918
+ [200, { "content-type" => "application/json" }, [response]]
903
919
  end
904
920
  end
905
921
 
@@ -1132,7 +1148,7 @@ module MCP
1132
1148
  message: message,
1133
1149
  },
1134
1150
  }
1135
- [400, { "Content-Type" => "application/json" }, [body.to_json]]
1151
+ [400, { "content-type" => "application/json" }, [body.to_json]]
1136
1152
  end
1137
1153
 
1138
1154
  def session_already_connected_response
data/lib/mcp/server.rb CHANGED
@@ -963,9 +963,7 @@ module MCP
963
963
  end
964
964
 
965
965
  def index_resources_by_uri(resources)
966
- resources.each_with_object({}) do |resource, hash|
967
- hash[resource.uri] = resource
968
- end
966
+ resources.to_h { |resource| [resource.uri, resource] }
969
967
  end
970
968
 
971
969
  def error_tool_response(text)
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.24.0"
4
+ VERSION = "0.25.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -45,8 +45,10 @@ files:
45
45
  - lib/mcp/client/http.rb
46
46
  - lib/mcp/client/oauth.rb
47
47
  - lib/mcp/client/oauth/client_credentials_provider.rb
48
+ - lib/mcp/client/oauth/cross_app_access_provider.rb
48
49
  - lib/mcp/client/oauth/discovery.rb
49
50
  - lib/mcp/client/oauth/flow.rb
51
+ - lib/mcp/client/oauth/id_jag_token_exchange.rb
50
52
  - lib/mcp/client/oauth/in_memory_storage.rb
51
53
  - lib/mcp/client/oauth/jwt_client_assertion.rb
52
54
  - lib/mcp/client/oauth/pkce.rb
@@ -95,7 +97,7 @@ licenses:
95
97
  - Apache-2.0
96
98
  metadata:
97
99
  allowed_push_host: https://rubygems.org
98
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.24.0
100
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.25.0
99
101
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
100
102
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
101
103
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
@@ -114,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
114
116
  - !ruby/object:Gem::Version
115
117
  version: '0'
116
118
  requirements: []
117
- rubygems_version: 4.0.10
119
+ rubygems_version: 4.0.16
118
120
  specification_version: 4
119
121
  summary: The official Ruby SDK for Model Context Protocol servers and clients
120
122
  test_files: []