mcp 1.4.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: 402b424bd7e0cf6abce69f6613b929d63a259d8d71fbc479378a0de40e2b6bde
4
- data.tar.gz: b4fa00e094ab14bf0adf36165cb6cda2e0747cb369fff727227bad16edbd4090
3
+ metadata.gz: 19daecce4ccef4ae391f54643fc8fca17360af39e58541404d090dab52858db8
4
+ data.tar.gz: 75f1cac8a1b8c89b6a6a067b763751f39184e1846de5cfe317b3f8e3886fc5a0
5
5
  SHA512:
6
- metadata.gz: 50741bdb178837e7f87458c91f1b5c0d678139f4949099321d093c0c9936542e6c01b7666b393c5009ad88c78aacac2aa32c28ab5c6c2cdb871bbb09326b9a96
7
- data.tar.gz: 75f6bce397dd2034e6315ed33ad9bf908656e5e4ecb53f625d40053d38a7b7373561d87a13798551517cb338798083dabe8e46d4cf4218c76fbcd99536bdf35c
6
+ metadata.gz: c1653c778f8e782c1ed7c4b66c538076fdce5f35fa00af054f1c9d06c1e78335a02e91734cc90d6107f8ae089946d5085d56d6fc2eeb27a11c84cc6e3f663737
7
+ data.tar.gz: 29cf1b6ee37eed02b83745ce09731db507f64cb95dcb8376ad154525415807db717c8aebd96257c8261eda19b677317a719e8fbd37e0acf3d55e7d8aa1fd8883
@@ -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
 
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.4.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.4.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.4.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