mcp 1.3.0 → 1.5.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: 237a819736a62982990aafa3889226e94101be576d2a2c917b4ce3f9be8bc5e1
4
- data.tar.gz: 12bb5f7d9b6ecc0399cd3d6f591a21dbc2c0f087b7c8b0ffd9d38621d496671d
3
+ metadata.gz: 19daecce4ccef4ae391f54643fc8fca17360af39e58541404d090dab52858db8
4
+ data.tar.gz: 75f1cac8a1b8c89b6a6a067b763751f39184e1846de5cfe317b3f8e3886fc5a0
5
5
  SHA512:
6
- metadata.gz: aed9090f1de375b2fbd54432e7dd5f2b59c50f0a1875594f6319c07fb213047e296579e85ecfc23941df58512b52eae8a4ba34e1b298156306f470b0632180f4
7
- data.tar.gz: 179d5d8e4a88985fb1e5e50cc66a9b5b8860abdf3484d935cde65b04d1f9f9295ddec625c037984698e9e5ad5e993091ac082243202132292cde100a8830dabc
6
+ metadata.gz: c1653c778f8e782c1ed7c4b66c538076fdce5f35fa00af054f1c9d06c1e78335a02e91734cc90d6107f8ae089946d5085d56d6fc2eeb27a11c84cc6e3f663737
7
+ data.tar.gz: 29cf1b6ee37eed02b83745ce09731db507f64cb95dcb8376ad154525415807db717c8aebd96257c8261eda19b677317a719e8fbd37e0acf3d55e7d8aa1fd8883
data/README.md CHANGED
@@ -9,7 +9,7 @@ Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io.
9
9
  - Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host
10
10
  - Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization
11
11
  - Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration
12
- - Cover the full protocol surface: server-to-client requests, multi round-trip results, notifications, progress, logging, cancellation, completions, and pagination
12
+ - Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination
13
13
 
14
14
  ## Installation
15
15
 
@@ -1118,6 +1118,19 @@ module MCP
1118
1118
  },
1119
1119
  }
1120
1120
  end
1121
+ elsif message["method"] == MCP::Methods::PING && (message["id"].is_a?(String) || message["id"].is_a?(Numeric))
1122
+ # The ping receiver "MUST respond promptly with an empty response" (MCP ping utility),
1123
+ # so an unhandled ping is answered with the empty result rather than Method not found.
1124
+ # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
1125
+ # schema validation, so a ping carrying one falls through to Method not found instead.
1126
+ # The default lives here instead of in `@server_request_handlers`, whose `any?` opens
1127
+ # the GET listening stream on connect: a built-in pong must not change listener behavior.
1128
+ # A handler registered via `on_server_request("ping")` still wins through the branch above.
1129
+ {
1130
+ jsonrpc: JsonRpcHandler::Version::V2_0,
1131
+ id: message["id"],
1132
+ result: {},
1133
+ }
1121
1134
  else
1122
1135
  {
1123
1136
  jsonrpc: JsonRpcHandler::Version::V2_0,
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # The decision an `authorization_request_validator` is asked to approve: which authorization server this client is about to send the user to,
7
+ # and with which scopes.
8
+ #
9
+ # Handed over as one object rather than as keyword arguments so that later revisions of the MCP authorization specification can add to it
10
+ # without changing the shape every implementer wrote.
11
+ #
12
+ # Only the named readers are the contract. The positional access `Struct` also provides (`request[0]`, `to_a`, `each`) is not,
13
+ # so the representation can change later.
14
+ AuthorizationRequest = Struct.new(
15
+ # `issuer` of the authorization server selected from the MCP server's Protected Resource Metadata.
16
+ :authorization_server,
17
+ # The scopes about to be requested, as an Array. Chosen by the MCP server, through the `WWW-Authenticate` challenge or `scopes_supported`.
18
+ :scopes,
19
+ # The MCP server URL this client was configured with.
20
+ :server_url,
21
+ # The RFC 8707 `resource` the token is being requested for.
22
+ :resource,
23
+ keyword_init: true,
24
+ )
25
+ end
26
+ end
27
+ end
@@ -60,7 +60,8 @@ module MCP
60
60
  private_key: nil,
61
61
  signing_algorithm: nil,
62
62
  scope: nil,
63
- storage: nil
63
+ storage: nil,
64
+ authorization_request_validator: nil
64
65
  )
65
66
  if blank?(client_id)
66
67
  raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
@@ -103,6 +104,7 @@ module MCP
103
104
  @signing_algorithm = signing_algorithm
104
105
  @scope = scope
105
106
  @storage = storage || InMemoryStorage.new
107
+ @authorization_request_validator = authorization_request_validator
106
108
  @storage.save_client_information(client_information)
107
109
  end
108
110
 
@@ -35,7 +35,7 @@ module MCP
35
35
 
36
36
  attr_reader :scope, :storage
37
37
 
38
- def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil)
38
+ def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
39
39
  if blank?(client_id)
40
40
  raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
41
41
  end
@@ -51,6 +51,7 @@ module MCP
51
51
  @assertion_provider = assertion_provider
52
52
  @scope = scope
53
53
  @storage = storage || InMemoryStorage.new
54
+ @authorization_request_validator = authorization_request_validator
54
55
  @storage.save_client_information(
55
56
  "client_id" => client_id,
56
57
  "client_secret" => client_secret,
@@ -23,6 +23,11 @@ module MCP
23
23
  # should leave the refresh token intact.
24
24
  class InvalidGrantError < AuthorizationError; end
25
25
 
26
+ # Raised when `authorization_request_validator` returns a falsy value. Separate from its parent
27
+ # so that a caller can tell a refusal by its own policy from a network, discovery,
28
+ # or authorization server metadata failure by rescuing a class rather than by matching the message text.
29
+ class AuthorizationRefusedError < AuthorizationError; end
30
+
26
31
  def initialize(provider:, http_client_factory: nil)
27
32
  @provider = provider
28
33
  @http_client_factory = http_client_factory || -> { default_http_client }
@@ -63,17 +68,22 @@ module MCP
63
68
 
64
69
  case provider_authorization_flow
65
70
  when :client_credentials
66
- return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
71
+ return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
67
72
  when :jwt_bearer
68
- return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
73
+ return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
69
74
  end
70
75
 
71
76
  ensure_pkce_supported!(as_metadata)
72
77
 
73
- client_info = ensure_client_registered(as_metadata: as_metadata)
74
-
75
78
  effective_scope = resolve_scope(scope: scope, prm: prm || {})
76
79
  effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
80
+
81
+ # Asked before registering, not after: a refusal must not leave this client registered at an authorization server
82
+ # the embedding application has just rejected.
83
+ authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
84
+
85
+ client_info = ensure_client_registered(as_metadata: as_metadata)
86
+
77
87
  pkce = PKCE.generate
78
88
  state = SecureRandom.urlsafe_base64(32)
79
89
 
@@ -109,7 +119,7 @@ module MCP
109
119
  resource: resource,
110
120
  )
111
121
 
112
- @provider.save_tokens(tokens)
122
+ save_tokens_issued_by(tokens, as_metadata: as_metadata)
113
123
  :authorized
114
124
  end
115
125
 
@@ -119,17 +129,19 @@ module MCP
119
129
  # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
120
130
  # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
121
131
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
122
- def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
132
+ def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:)
123
133
  client_info = client_credentials_client_info
124
134
  ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
125
135
 
126
136
  form = { "grant_type" => "client_credentials" }
127
137
  effective_scope = resolve_scope(scope: scope, prm: prm)
138
+ authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
128
139
  form["scope"] = effective_scope if effective_scope
129
140
  form["resource"] = resource if resource
130
141
 
131
142
  tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
132
- @provider.save_tokens(tokens)
143
+ save_tokens_issued_by(tokens, as_metadata: as_metadata)
144
+
133
145
  :authorized
134
146
  end
135
147
 
@@ -173,12 +185,18 @@ module MCP
173
185
  # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
174
186
  # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
175
187
  # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
176
- def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
188
+ def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:)
177
189
  client_info = @provider.client_information
178
190
  unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
179
191
  raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
180
192
  end
181
193
 
194
+ # Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant:
195
+ # obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once
196
+ # the host has refused it.
197
+ effective_scope = resolve_scope(scope: scope, prm: prm)
198
+ authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
199
+
182
200
  assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
183
201
  if assertion.nil? || assertion.to_s.empty?
184
202
  raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
@@ -188,12 +206,11 @@ module MCP
188
206
  "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
189
207
  "assertion" => assertion,
190
208
  }
191
- effective_scope = resolve_scope(scope: scope, prm: prm)
192
209
  form["scope"] = effective_scope if effective_scope
193
210
  form["resource"] = resource if resource
194
211
 
195
212
  tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
196
- @provider.save_tokens(tokens)
213
+ save_tokens_issued_by(tokens, as_metadata: as_metadata)
197
214
  :authorized
198
215
  end
199
216
 
@@ -242,6 +259,8 @@ module MCP
242
259
  server_url: server_url,
243
260
  )
244
261
 
262
+ ensure_token_issuer!(as_metadata: as_metadata)
263
+
245
264
  client_info = if have_stored_client_info
246
265
  # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
247
266
  # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
@@ -262,7 +281,7 @@ module MCP
262
281
  resource: resource,
263
282
  )
264
283
 
265
- @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
284
+ save_tokens_issued_by(preserve_refresh_token(new_tokens, refresh_token), as_metadata: as_metadata)
266
285
  :refreshed
267
286
  end
268
287
 
@@ -499,6 +518,43 @@ module MCP
499
518
  # the authorization server on two loopback ports), and deployments that never leave a corporate network.
500
519
  # Only a server reachable on the public internet is barred from steering the client inward.
501
520
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
521
+ # Hands the embedding application the authorization server and the scopes that are about to be requested,
522
+ # and abandons the flow when it refuses them.
523
+ #
524
+ # Both values are chosen by the MCP server: it names its own authorization server in Protected
525
+ # Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge.
526
+ # Neither the specification nor any MCP SDK binds that choice to the server's own identity,
527
+ # and validating that a token was issued for the intended audience is a responsibility the specification
528
+ # places on MCP servers rather than on clients.
529
+ # A host that knows which providers its user deals with can apply that knowledge here.
530
+ #
531
+ # The scopes are passed on unchanged whatever the host decides, because the specification requires
532
+ # a client to treat the challenged scopes as authoritative for the operation; the choice offered is
533
+ # to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
534
+ #
535
+ # Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
536
+ # this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
537
+ # an authorization server that has changed since the tokens were issued sends the flow back through here,
538
+ # where the host sees the new one and decides again.
539
+ def authorize_request!(as_metadata:, scope:, server_url:, resource:)
540
+ return unless @provider.respond_to?(:authorization_request_validator)
541
+ return unless (validator = @provider.authorization_request_validator)
542
+
543
+ request = AuthorizationRequest.new(
544
+ authorization_server: as_metadata["issuer"],
545
+ scopes: scope.to_s.split,
546
+ server_url: server_url,
547
+ resource: resource,
548
+ ).freeze
549
+
550
+ return if validator.call(request)
551
+
552
+ raise AuthorizationRefusedError, <<~MESSAGE
553
+ The authorization request was refused by `authorization_request_validator` \
554
+ (authorization server #{request.authorization_server.inspect}, scopes #{request.scopes.inspect}).
555
+ MESSAGE
556
+ end
557
+
502
558
  def ensure_routable_destination!(url, label:, server_url:)
503
559
  return unless private_network_url?(url)
504
560
  return if private_network_url?(server_url)
@@ -669,6 +725,39 @@ module MCP
669
725
  # the error and falls back to the full flow, which discards the stale registration and re-registers.
670
726
  # Credentials without an `"issuer"` binding predate this check and are allowed through;
671
727
  # CIMD `client_id`s are portable.
728
+ # Records which authorization server minted these tokens, alongside the tokens themselves.
729
+ # SEP-2352 already binds *client credentials* to their issuer; this binds the *tokens*, which that
730
+ # SEP leaves unbound.
731
+ # Stored in the token hash so it travels through any `storage` a caller supplied, without widening
732
+ # the storage interface that custom implementations have to satisfy.
733
+ def save_tokens_issued_by(tokens, as_metadata:)
734
+ issuer = as_metadata["issuer"]
735
+ tokens = tokens.merge("issuer" => issuer) if tokens.is_a?(Hash) && issuer
736
+
737
+ @provider.save_tokens(tokens)
738
+ end
739
+
740
+ # Refuses to present a refresh token to an authorization server other than the one that issued it.
741
+ # `refresh!` rediscovers the authorization server from Protected Resource Metadata every time,
742
+ # so the server named for a session can differ from the one the stored tokens came from.
743
+ # Unlike `ensure_refreshable_client_information!` this holds whatever the client identity is,
744
+ # including a Client ID Metadata Document URL, which is portable across authorization servers precisely
745
+ # so that re-registration is unnecessary.
746
+ #
747
+ # Tokens stored before this shipped carry no issuer and are left alone, matching how SEP-2352 treats
748
+ # client information without one; the binding takes effect from their next authorization.
749
+ # The caller answers a refusal by running the full authorization flow, which is where the embedding application
750
+ # is asked about the new authorization server.
751
+ def ensure_token_issuer!(as_metadata:)
752
+ stored_issuer = read_token("issuer")
753
+ return if stored_issuer.nil? || stored_issuer == as_metadata["issuer"]
754
+
755
+ raise AuthorizationError, <<~MESSAGE
756
+ Cannot refresh: the stored tokens were issued by a different authorization server (stored issuer #{stored_issuer.inspect}, \
757
+ current #{as_metadata["issuer"].inspect}); re-authorization is required.
758
+ MESSAGE
759
+ end
760
+
672
761
  def ensure_refreshable_client_information!(client_info, as_metadata:)
673
762
  stored_issuer = client_info_required_value(client_info, "issuer")
674
763
  return if stored_issuer.nil?
@@ -86,7 +86,8 @@ module MCP
86
86
  callback_handler:,
87
87
  scope: nil,
88
88
  storage: nil,
89
- client_id_metadata_document_url: nil
89
+ client_id_metadata_document_url: nil,
90
+ authorization_request_validator: nil
90
91
  )
91
92
  unless Discovery.secure_url?(redirect_uri)
92
93
  raise InsecureRedirectURIError,
@@ -115,6 +116,7 @@ module MCP
115
116
  @scope = scope
116
117
  @storage = storage || InMemoryStorage.new
117
118
  @client_id_metadata_document_url = client_id_metadata_document_url
119
+ @authorization_request_validator = authorization_request_validator
118
120
  end
119
121
 
120
122
  # Identifies the OAuth flow this provider drives.
@@ -14,6 +14,13 @@ module MCP
14
14
  # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
15
15
  # (see `InMemoryStorage`).
16
16
  module StorageBackedProvider
17
+ # Optional `->(request) { true | false }` hook, called with an `AuthorizationRequest`.
18
+ # An MCP server names its own authorization server and the scopes to ask for, so this is where
19
+ # an embedding application sees both before the request is made and can decline to proceed.
20
+ # `nil` (the default) authorizes whatever the server asked for, which is the behavior every
21
+ # MCP SDK has today.
22
+ attr_reader :authorization_request_validator
23
+
17
24
  def access_token
18
25
  tokens&.dig("access_token") || tokens&.dig(:access_token)
19
26
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "oauth/authorization_request"
3
4
  require_relative "oauth/bounded_body"
4
5
  require_relative "oauth/discovery"
5
6
  require_relative "oauth/flow"
@@ -436,6 +436,18 @@ module MCP
436
436
 
437
437
  parsed = JSON.parse(line.strip)
438
438
 
439
+ # A frame carrying `method` is a request or notification, never the awaited response,
440
+ # whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id
441
+ # may legitimately collide with the awaited one. Pings are answered;
442
+ # other server-to-client requests over stdio stay unsupported as documented,
443
+ # and notifications carry no id.
444
+ if parsed.is_a?(Hash) && parsed.key?("method")
445
+ # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
446
+ # schema validation, so a ping carrying one is skipped rather than echoed back.
447
+ answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"])
448
+ next
449
+ end
450
+
439
451
  # A JSON-RPC message is an object; skip a non-object frame (array or scalar)
440
452
  # the same way as a frame without an id.
441
453
  next unless parsed.is_a?(Hash) && parsed.key?("id")
@@ -451,6 +463,22 @@ module MCP
451
463
  )
452
464
  end
453
465
 
466
+ # The ping receiver "MUST respond promptly with an empty response". Answered inline from
467
+ # the read loop, so a keepalive cannot stall unanswered while a response is awaited;
468
+ # between requests nothing reads stdout, and a queued ping is answered on the next read.
469
+ def answer_ping(parsed)
470
+ @write_mutex.synchronize do
471
+ write_message({ jsonrpc: JsonRpcHandler::Version::V2_0, id: parsed["id"], result: {} })
472
+ end
473
+ rescue RequestHandlerError
474
+ # Best effort: a pong cannot be delivered over a broken stdin, and the failure must not
475
+ # surface as an error of the unrelated request whose response the loop is reading.
476
+ end
477
+
478
+ def json_rpc_id?(id)
479
+ id.is_a?(String) || id.is_a?(Numeric)
480
+ end
481
+
454
482
  def ensure_running!
455
483
  return if @wait_thread.alive?
456
484
 
@@ -129,6 +129,12 @@ module MCP
129
129
  # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
130
130
  # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
131
131
  # when an upstream proxy already keeps the stream alive.
132
+ # @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream.
133
+ # A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern,
134
+ # which builds a fresh transport per request and renders the body) passes `false`:
135
+ # the method then answers 404 with JSON-RPC `-32601` like any unimplemented method,
136
+ # and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags,
137
+ # keeping the advertisement and the actual behavior in agreement. Defaults to `true`.
132
138
  # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
133
139
  # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
134
140
  # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
@@ -145,6 +151,7 @@ module MCP
145
151
  max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
146
152
  max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
147
153
  listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
154
+ serve_subscriptions_listen: true,
148
155
  server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
149
156
  )
150
157
  super(server)
@@ -162,7 +169,8 @@ module MCP
162
169
  @allowed_origins = Array(allowed_origins).map(&:downcase).freeze
163
170
  @pending_responses = {}
164
171
 
165
- # Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter }` (SEP-2575).
172
+ # Maps a `subscriptions/listen` request id to
173
+ # `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575).
166
174
  # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
167
175
  # which is a follow-up.
168
176
  @listen_subscriptions = {}
@@ -211,6 +219,7 @@ module MCP
211
219
  end
212
220
 
213
221
  @listen_keepalive_interval = listen_keepalive_interval
222
+ @serve_subscriptions_listen = serve_subscriptions_listen
214
223
 
215
224
  unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
216
225
  raise ArgumentError, "server_to_client_request_timeout must be a positive number"
@@ -260,10 +269,12 @@ module MCP
260
269
  handle_request(Rack::Request.new(env))
261
270
  end
262
271
 
263
- # The `subscriptions/listen` notification stream (SEP-2575) is served on the modern path,
264
- # so `Server#discover` may advertise `listChanged`/`subscribe` capability flags.
272
+ # Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575).
273
+ # Gates both the route (a refusing transport answers the method as unimplemented) and
274
+ # the `listChanged`/`subscribe` capability flags `Server#discover` advertises,
275
+ # so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword.
265
276
  def serves_subscriptions_listen?
266
- true
277
+ @serve_subscriptions_listen
267
278
  end
268
279
 
269
280
  def handle_request(request)
@@ -723,8 +734,13 @@ module MCP
723
734
  return mismatch_error if mismatch_error
724
735
 
725
736
  # `subscriptions/listen` is a long-lived notification stream served at the transport layer;
726
- # it never dispatches through `Server#handle`.
727
- return handle_subscriptions_listen(body) if body[:method] == Methods::SUBSCRIPTIONS_LISTEN
737
+ # it never dispatches through `Server#handle`. A transport constructed with
738
+ # `serve_subscriptions_listen: false` skips the interception, so the method falls through
739
+ # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
740
+ # serve an open SSE stream needs, instead of a `Proc` body it can never call.
741
+ if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
742
+ return handle_subscriptions_listen(body)
743
+ end
728
744
 
729
745
  session = modern_session
730
746
  notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
@@ -881,17 +897,47 @@ module MCP
881
897
  )
882
898
  end
883
899
 
884
- # The proc registers the stream and returns, leaving the response open like
900
+ # The Rack streaming body of a `subscriptions/listen` response. It responds to `call`
901
+ # and deliberately not to `each`, so Rack keeps classifying it as a streaming body;
902
+ # `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)`
903
+ # in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix.
904
+ class ListenStreamBody
905
+ def initialize(&block)
906
+ @block = block
907
+ end
908
+
909
+ def call(stream)
910
+ @block.call(stream)
911
+ end
912
+
913
+ def first
914
+ raise <<~MESSAGE
915
+ subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \
916
+ A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \
917
+ so the method is answered as unimplemented instead.
918
+ See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns.
919
+ MESSAGE
920
+ end
921
+ end
922
+ private_constant :ListenStreamBody
923
+
924
+ # The body registers the stream and returns, leaving the response open like
885
925
  # the legacy GET stream (`create_sse_body`).
926
+ #
927
+ # Registration and activation are split on purpose: the entry is inserted inactive
928
+ # (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock,
929
+ # and only then does the entry become eligible for delivery. A concurrent notification between
930
+ # the insert and the acknowledgement write skips the inactive entry,
931
+ # enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
886
932
  def listen_sse_body(request_id, honored)
887
- proc do |stream|
933
+ ListenStreamBody.new do |stream|
888
934
  rejected = false
889
935
  @mutex.synchronize do
890
936
  if @listen_subscriptions.key?(request_id) ||
891
937
  (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
892
938
  rejected = true
893
939
  else
894
- @listen_subscriptions[request_id] = { stream: stream, filter: honored }
940
+ @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new }
895
941
  end
896
942
  end
897
943
 
@@ -909,6 +955,7 @@ module MCP
909
955
 
910
956
  begin
911
957
  send_to_stream(stream, acknowledgement)
958
+ activate_listen_subscription(request_id)
912
959
  start_listen_keepalive_thread(request_id)
913
960
  rescue *STREAM_WRITE_ERRORS
914
961
  remove_listen_subscription(request_id)
@@ -918,6 +965,15 @@ module MCP
918
965
  end
919
966
  end
920
967
 
968
+ # Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
969
+ # The entry may already be gone when the transport closed concurrently.
970
+ def activate_listen_subscription(request_id)
971
+ @mutex.synchronize do
972
+ subscription = @listen_subscriptions[request_id]
973
+ subscription[:active] = true if subscription
974
+ end
975
+ end
976
+
921
977
  # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
922
978
  # connection is detected and its slot freed, rather than held until the next fan-out write.
923
979
  # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
@@ -999,6 +1055,10 @@ module MCP
999
1055
  # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
1000
1056
  matched = @mutex.synchronize do
1001
1057
  @listen_subscriptions.filter_map do |request_id, subscription|
1058
+ # An inactive entry has not finished writing its acknowledgement yet;
1059
+ # delivering to it would put a notification ahead of the acknowledgement.
1060
+ next unless subscription[:active]
1061
+
1002
1062
  hit = if field
1003
1063
  subscription[:filter][field]
1004
1064
  else
@@ -1007,24 +1067,32 @@ module MCP
1007
1067
  uris.is_a?(Array) && uris.include?(uri)
1008
1068
  end
1009
1069
 
1010
- [request_id, subscription[:stream]] if hit
1070
+ [request_id, subscription] if hit
1011
1071
  end
1012
1072
  end
1013
1073
 
1014
- matched.each do |request_id, stream|
1074
+ matched.each do |request_id, subscription|
1015
1075
  meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
1016
1076
  notification_params = (params || {}).merge(_meta: meta)
1017
1077
  notification = { jsonrpc: "2.0", method: method, params: notification_params }
1018
1078
 
1019
1079
  begin
1020
- send_to_stream(stream, notification)
1080
+ # The per-stream write mutex orders this write against a concurrent graceful teardown:
1081
+ # once teardown has marked the entry closed and written its `SubscriptionsListenResult`,
1082
+ # a delivery that snapshotted the entry before the registry was cleared skips it instead
1083
+ # of writing after the final message.
1084
+ subscription[:write_mutex].synchronize do
1085
+ next if subscription[:closed]
1086
+
1087
+ send_to_stream(subscription[:stream], notification)
1088
+ end
1021
1089
  rescue *STREAM_WRITE_ERRORS => e
1022
1090
  MCP.configuration.exception_reporter.call(
1023
1091
  e,
1024
1092
  { subscription_id: request_id, error: "Failed to send notification" },
1025
1093
  )
1026
1094
  remove_listen_subscription(request_id)
1027
- close_stream_safely(stream)
1095
+ close_stream_safely(subscription[:stream])
1028
1096
  end
1029
1097
  end
1030
1098
  end
@@ -1043,20 +1111,27 @@ module MCP
1043
1111
  end
1044
1112
 
1045
1113
  removed.each do |request_id, subscription|
1046
- begin
1047
- send_to_stream(subscription[:stream], {
1048
- jsonrpc: "2.0",
1049
- id: request_id,
1050
- result: {
1051
- # `SubscriptionsListenResult` is served at the transport layer and never
1052
- # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1053
- # stamped at its construction site.
1054
- resultType: ResultType::COMPLETE,
1055
- _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1056
- },
1057
- })
1058
- rescue *STREAM_WRITE_ERRORS
1059
- nil
1114
+ # Marking the entry closed and writing the result under the stream's write mutex orders
1115
+ # this against in-flight deliveries: each one either lands before the result or observes
1116
+ # `closed` and skips, keeping the graceful result the stream's final message.
1117
+ subscription[:write_mutex].synchronize do
1118
+ subscription[:closed] = true
1119
+
1120
+ begin
1121
+ send_to_stream(subscription[:stream], {
1122
+ jsonrpc: "2.0",
1123
+ id: request_id,
1124
+ result: {
1125
+ # `SubscriptionsListenResult` is served at the transport layer and never
1126
+ # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1127
+ # stamped at its construction site.
1128
+ resultType: ResultType::COMPLETE,
1129
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1130
+ },
1131
+ })
1132
+ rescue *STREAM_WRITE_ERRORS
1133
+ nil
1134
+ end
1060
1135
  end
1061
1136
  close_stream_safely(subscription[:stream])
1062
1137
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.3.0"
4
+ VERSION = "1.5.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: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -46,6 +46,7 @@ files:
46
46
  - lib/mcp/client/mcp_param_headers.rb
47
47
  - lib/mcp/client/modern_envelope.rb
48
48
  - lib/mcp/client/oauth.rb
49
+ - lib/mcp/client/oauth/authorization_request.rb
49
50
  - lib/mcp/client/oauth/bounded_body.rb
50
51
  - lib/mcp/client/oauth/client_credentials_provider.rb
51
52
  - lib/mcp/client/oauth/cross_app_access_provider.rb
@@ -107,7 +108,7 @@ licenses:
107
108
  - Apache-2.0
108
109
  metadata:
109
110
  allowed_push_host: https://rubygems.org
110
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.3.0
111
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.5.0
111
112
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
112
113
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
113
114
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues