mcp 1.7.0 → 1.8.1

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: 87f6233cbd083a14b47183b21558ee995c06953ef5bb8d9ea568eca8e69103de
4
- data.tar.gz: 11697a1b6e71297211fac5ecf411ac381a064e66274ac9c9a9640087284859ad
3
+ metadata.gz: 255e316335616783063d8a7374f385ff10b99c9ddef24571734e19b6ec6f5f93
4
+ data.tar.gz: 07021e227f5b1be05fd2c1e4dc88e0d563bc963b6f1190f998aa632ed6871031
5
5
  SHA512:
6
- metadata.gz: ec9899cc307eb88706aded076b0e7be9bb2bdbf2fbf52c3d8f8f7a7ebe05dbb1da802ebfc8ea9bc1d5ecc18c361a2249488815c60175650203815a084aad9833
7
- data.tar.gz: c43d95b541108f64ac594567e44f3de9e103f4f52c2c122ef0f0d90dcaa52b391b419c536cf4653c784299324a71c23c9bb7da9a7c4f9f69db87c2ab1d730871
6
+ metadata.gz: 3d496817c30ae59b7f99704fead73cc9dfe5d926a87455a5c51fcc766bdf299d943ce86c0d43ca859d870a488df191cc0a4bdeba302b95d8ebcf084730743a68
7
+ data.tar.gz: 6291785257c054baf3570fb9baef4ecb1db006c920608ad69239460e2494610fa9133341bdf903a4c127f7ab40146f2bf9eecb34266621cba172a08ef52812f0
data/README.md CHANGED
@@ -6,7 +6,7 @@ Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io.
6
6
 
7
7
  ## Features
8
8
 
9
- - Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host
9
+ - Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host, with OAuth 2.1 resource-server protection
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
12
  - Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination
@@ -55,14 +55,19 @@ module JsonRpcHandler
55
55
 
56
56
  def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
57
57
  begin
58
- request = JSON.parse(request_json, symbolize_names: true)
58
+ begin
59
+ request = JSON.parse(request_json, symbolize_names: true)
60
+ rescue JSON::ParserError, EncodingError
61
+ # `symbolize_names` raises `EncodingError` for a key that is not valid UTF-8, a parse error as well.
62
+ # Rescued around the parse only, so an error raised while handling keeps its own classification.
63
+ return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
64
+ code: ErrorCode::PARSE_ERROR,
65
+ message: "Parse error",
66
+ data: "Invalid JSON",
67
+ }).to_json
68
+ end
69
+
59
70
  response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
60
- rescue JSON::ParserError
61
- response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
62
- code: ErrorCode::PARSE_ERROR,
63
- message: "Parse error",
64
- data: "Invalid JSON",
65
- })
66
71
  rescue StandardError
67
72
  # Last-resort guard so an unexpected handling error returns a JSON-RPC error
68
73
  # instead of escaping to the transport loop.
@@ -90,17 +95,25 @@ module JsonRpcHandler
90
95
 
91
96
  error = if !valid_version?(request[:jsonrpc])
92
97
  "JSON-RPC version must be 2.0"
98
+ elsif request.key?(:id) && id.nil?
99
+ # JSON-RPC 2.0 allows a null id; MCP rules it out: a request carries a string or an integer id,
100
+ # and a notification carries no id member at all. Refused rather than run as a notification,
101
+ # since a sender that wrote an id meant to be answered.
102
+ "Request ID must not be null"
93
103
  elsif !valid_id?(id, id_validation_pattern)
94
- "Request ID must match validation pattern, or be an integer or null"
104
+ if id_validation_pattern
105
+ "Request ID must be a string matching the validation pattern, or an integer"
106
+ else
107
+ "Request ID must be a string or an integer"
108
+ end
95
109
  elsif !valid_method_name?(request[:method])
96
110
  'Method name must be a string and not start with "rpc."'
97
111
  end
98
112
 
99
113
  # Per JSON-RPC 2.0 (Response object, `id`), the error response must carry
100
- # the same id as the request when the id could be detected; null is only
101
- # for requests whose id could not be determined. `error_response` nils out
102
- # ids that fail validation, and the `:unknown_id` sentinel keeps a response
103
- # (with a null id) being emitted when the request carried no id at all.
114
+ # the same id as the request when the id could be detected. `error_response`
115
+ # leaves the id out for ids that fail validation, and the `:unknown_id` sentinel
116
+ # keeps a response (without an id) being emitted when the request carried no id at all.
104
117
  return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
105
118
  code: ErrorCode::INVALID_REQUEST,
106
119
  message: "Invalid Request",
@@ -176,7 +189,9 @@ module JsonRpcHandler
176
189
 
177
190
  def valid_id?(id, pattern = nil)
178
191
  return true if id.nil? || id.is_a?(Integer)
179
- return false unless id.is_a?(String)
192
+ # A string that is not valid in its encoding cannot be written into the response,
193
+ # and matching a pattern against it raises, so it is refused before the method runs.
194
+ return false unless id.is_a?(String) && id.valid_encoding?
180
195
 
181
196
  pattern ? id.match?(pattern) : true
182
197
  end
@@ -197,11 +212,15 @@ module JsonRpcHandler
197
212
  } unless id.nil?
198
213
  end
199
214
 
215
+ # An error response for a request whose id could not be read carries no `id` member: JSON-RPC 2.0 puts
216
+ # a null there, but the MCP schema allows only a string or an integer, and the Streamable HTTP transport
217
+ # specification describes such a body as an error response that has no id.
200
218
  def error_response(id:, id_validation_pattern:, error:)
201
- {
202
- jsonrpc: Version::V2_0,
203
- id: valid_id?(id, id_validation_pattern) ? id : nil,
204
- error: error.compact,
205
- } unless id.nil?
219
+ return if id.nil?
220
+
221
+ response = { jsonrpc: Version::V2_0 }
222
+ response[:id] = id if valid_id?(id, id_validation_pattern)
223
+ response[:error] = error.compact
224
+ response
206
225
  end
207
226
  end
@@ -120,7 +120,7 @@ module MCP
120
120
  # the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
121
121
  # and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
122
122
  class SSEStream
123
- attr_reader :buffer, :response, :last_event_id, :retry_ms
123
+ attr_reader :buffer, :response, :response_headers, :last_event_id, :retry_ms
124
124
  attr_accessor :abortable
125
125
 
126
126
  def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil)
@@ -132,6 +132,7 @@ module MCP
132
132
  @response = nil
133
133
  @last_event_id = nil
134
134
  @retry_ms = nil
135
+ @response_headers = nil
135
136
  end
136
137
 
137
138
  # Whether the server sent a SEP-1699 priming event (any event carrying an id),
@@ -146,9 +147,10 @@ module MCP
146
147
  # abort the stream so an over-limit tail cannot mask a response that already arrived.
147
148
  def on_data
148
149
  proc do |chunk, _received_bytes, env|
150
+ @response_headers ||= env.response_headers if env
149
151
  if event_stream?(env)
150
152
  feed(chunk)
151
- raise StreamAbort if @abortable && @response
153
+ raise StreamAbort if @abortable && @response && !error_status?(env)
152
154
 
153
155
  if parser_buffered_bytes > @max_message_bytes
154
156
  raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing"
@@ -163,6 +165,16 @@ module MCP
163
165
  end
164
166
  end
165
167
 
168
+ # An error status must reach Faraday's `raise_error` middleware (which drives the
169
+ # OAuth and step-up retries in `send_request`), so the stream is read to EOF instead
170
+ # of aborting when the server answered with one. This is the behavior every
171
+ # method had before the abort shortcut. It is the only case that still needs
172
+ # the finished response object.
173
+ def error_status?(env)
174
+ status = env.status if env.respond_to?(:status)
175
+ status.is_a?(Integer) && status >= 400
176
+ end
177
+
166
178
  # A fresh parser for a new HTTP connection (reconnection GET),
167
179
  # so a partial line from the previous stream cannot corrupt the next one.
168
180
  def reset_parser!
@@ -202,9 +214,10 @@ module MCP
202
214
 
203
215
  if parsed.key?("result") || parsed.key?("error")
204
216
  @response ||= parsed
205
- elsif parsed["method"] && parsed.key?("id")
206
- # A server-to-client request (e.g. `elicitation/create`) delivered
207
- # on the stream while the original request is still pending.
217
+ elsif parsed["method"] && parsed.key?("id") && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
218
+ # A server-to-client request (e.g. `elicitation/create`) delivered on the stream while
219
+ # the original request is still pending. One that is not JSON-RPC 2.0 is dropped unanswered,
220
+ # as the TypeScript client drops it at schema validation.
208
221
  @on_request&.call(parsed)
209
222
  end
210
223
  end
@@ -399,6 +412,7 @@ module MCP
399
412
  )
400
413
  end
401
414
 
415
+ request_id = request[:id] || request["id"]
402
416
  method = request[:method] || request["method"]
403
417
  params = request[:params] || request["params"]
404
418
  oauth_retried = false
@@ -406,10 +420,13 @@ module MCP
406
420
 
407
421
  begin
408
422
  # The response is consumed incrementally so that an SSE stream the server holds open
409
- # (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF
410
- # so the response object (and its `Mcp-Session-Id` header) is always available for capture.
423
+ # (or closes early per SEP-1699) can be handled. Every request, `initialize` included,
424
+ # aborts once its response event arrives: `SSEStream` has already captured the response
425
+ # headers from the streaming `env` by then, so `Mcp-Session-Id` survives the abort.
426
+ # When `env` is unavailable (Faraday < 2.5 or an adapter without `on_data` support)
427
+ # nothing aborts and the stream is still read to EOF, as before.
411
428
  stream = SSEStream.new(
412
- abortable: method.to_s != MCP::Methods::INITIALIZE,
429
+ abortable: true,
413
430
  max_message_bytes: @max_message_bytes,
414
431
  on_request: ->(message) { dispatch_server_request(message) },
415
432
  )
@@ -417,16 +434,22 @@ module MCP
417
434
  yield if block_given?
418
435
 
419
436
  response = begin
420
- client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
421
- req.options.on_data = stream.on_data
437
+ redacting_authorization_header do
438
+ client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
439
+ req.options.on_data = stream.on_data
440
+ end
422
441
  end
423
442
  rescue StreamAbort
424
443
  nil
425
444
  end
426
445
 
427
- body = resolve_response_body(stream, response, method, params)
446
+ body = resolve_response_body(stream, response, request_id, method, params)
447
+
448
+ # Every way a response arrives (JSON body, SSE event, resumed stream) ends here. Checked before
449
+ # anything is learned from the body, and only for a request: a notification awaits no response.
450
+ reject_invalid_response!(body, method, params) if request_id
428
451
 
429
- capture_session_info(method, response, body) if response
452
+ capture_session_info(method, response ? response.headers : stream.response_headers, body)
430
453
  capture_mcp_param_declarations(method, params, body)
431
454
 
432
455
  body
@@ -522,7 +545,7 @@ module MCP
522
545
  def send_notification(notification:)
523
546
  method = notification[:method] || notification["method"]
524
547
 
525
- client.post("", notification, session_headers)
548
+ redacting_authorization_header { client.post("", notification, session_headers) }
526
549
  nil
527
550
  rescue Faraday::Error => e
528
551
  raise RequestHandlerError.new(
@@ -550,7 +573,7 @@ module MCP
550
573
  end
551
574
 
552
575
  begin
553
- client.delete("", nil, session_headers)
576
+ redacting_authorization_header { client.delete("", nil, session_headers) }
554
577
  rescue Faraday::ClientError => e
555
578
  raise unless [404, 405].include?(e.response&.dig(:status))
556
579
  ensure
@@ -955,11 +978,11 @@ module MCP
955
978
  !(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty?
956
979
  end
957
980
 
958
- def capture_session_info(method, response, body)
981
+ def capture_session_info(method, headers, body)
959
982
  return unless method.to_s == Methods::INITIALIZE
960
983
 
961
984
  # Faraday normalizes header names to lowercase.
962
- session_id = response.headers[SESSION_ID_HEADER.downcase]
985
+ session_id = headers[SESSION_ID_HEADER.downcase]
963
986
  @session_id ||= session_id unless session_id.to_s.empty?
964
987
  @protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
965
988
  end
@@ -1045,6 +1068,51 @@ module MCP
1045
1068
  [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
1046
1069
  end
1047
1070
 
1071
+ # Runs a request on the transport's connection and, when Faraday raises, replaces the value of the `Authorization` header
1072
+ # in the request headers the exception retains before it leaves the transport.
1073
+ # Faraday's `raise_error` middleware keeps a reference to `env.request_headers` in `Faraday::Error#response`
1074
+ # under `:request`, so the exception that becomes `RequestHandlerError#original_error` (and its `cause`) would otherwise print
1075
+ # the bearer token through `inspect`, which is what error reporters and log lines do with it. The request is complete by
1076
+ # the time the exception surfaces, and every request builds its own headers from the connection's defaults,
1077
+ # so the rewrite reaches neither those defaults nor a later request.
1078
+ def redacting_authorization_header
1079
+ yield
1080
+ rescue Faraday::Error => e
1081
+ redact_authorization_header!(e)
1082
+ raise
1083
+ end
1084
+
1085
+ # `raise_error` retains the request as a Hash under `:request`; a JSON middleware the customizer added raises
1086
+ # `Faraday::ParsingError` with the `Faraday::Response` itself, whose `env` holds the request headers.
1087
+ def redact_authorization_header!(error)
1088
+ response = error.response
1089
+
1090
+ if response.is_a?(Hash)
1091
+ request = response[:request]
1092
+ redact_authorization_header_in!(request, :headers) if request.is_a?(Hash)
1093
+ elsif response.respond_to?(:env) && response.env.respond_to?(:request_headers)
1094
+ redact_authorization_header_in!(response.env, :request_headers)
1095
+ end
1096
+ end
1097
+
1098
+ # The headers are a `Faraday::Utils::Headers`, which spells the name `Authorization` whatever the caller wrote,
1099
+ # unless a customizer middleware replaced them with a plain Hash or froze them: the name is matched regardless
1100
+ # of case so such a Hash is covered too, and a frozen object is swapped for a copy, so the redaction never
1101
+ # raises in place of the error it is redacting.
1102
+ def redact_authorization_header_in!(holder, key)
1103
+ headers = holder[key]
1104
+ return unless headers.is_a?(Hash)
1105
+
1106
+ names = headers.each_key.select { |name| name.to_s.casecmp?("authorization") }
1107
+ return if names.empty?
1108
+
1109
+ headers = holder[key] = headers.dup if headers.frozen?
1110
+
1111
+ names.each do |name|
1112
+ headers[name] = "[redacted]"
1113
+ end
1114
+ end
1115
+
1048
1116
  def require_faraday!
1049
1117
  require "faraday"
1050
1118
  rescue LoadError
@@ -1054,8 +1122,11 @@ module MCP
1054
1122
  end
1055
1123
 
1056
1124
  # Determines the logical JSON-RPC body of the exchange after the POST stream finished
1057
- # (or was aborted because the response already arrived).
1058
- def resolve_response_body(stream, response, method, params)
1125
+ # (or was aborted because the response already arrived). A notification (no `request_id`) awaits
1126
+ # no response, so any success status acknowledges it, as it does for the TypeScript and Python clients,
1127
+ # which check the content type of a response only for a request; the spec has the server answer
1128
+ # a notification with 202, but a server answering with another 2xx is accepted as well.
1129
+ def resolve_response_body(stream, response, request_id, method, params)
1059
1130
  return stream.response if stream.response
1060
1131
 
1061
1132
  # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
@@ -1073,6 +1144,9 @@ module MCP
1073
1144
  return stream.response if stream.response
1074
1145
  end
1075
1146
 
1147
+ # A stream that ends without a response has acknowledged a notification; there is nothing to resume for.
1148
+ return if request_id.nil? && response.success?
1149
+
1076
1150
  # SEP-1699: a graceful close after a priming event (an event with an id) but before
1077
1151
  # the response means "reconnect via GET to resume".
1078
1152
  return await_response_after_disconnect(stream, method, params) if stream.primed?
@@ -1089,6 +1163,8 @@ module MCP
1089
1163
  # added by the Faraday customizer may have parsed it already.
1090
1164
  response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
1091
1165
  else
1166
+ return if request_id.nil? && response.success?
1167
+
1092
1168
  raise RequestHandlerError.new(
1093
1169
  "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
1094
1170
  { method: method, params: params },
@@ -1157,7 +1233,7 @@ module MCP
1157
1233
  end
1158
1234
 
1159
1235
  def send_client_response(response)
1160
- client.post("", response, session_headers)
1236
+ redacting_authorization_header { client.post("", response, session_headers) }
1161
1237
  end
1162
1238
 
1163
1239
  def parse_json_buffer(buffer, method, params)
@@ -1172,6 +1248,20 @@ module MCP
1172
1248
  )
1173
1249
  end
1174
1250
 
1251
+ # A response is a JSON object whose `jsonrpc` is exactly "2.0". No body at all (202, or an empty 200)
1252
+ # is not a message and is left to the caller as before; a JSON `null` body parses to the same `nil`.
1253
+ # The received value is left out of the message because it can be any JSON value.
1254
+ def reject_invalid_response!(body, method, params)
1255
+ return if body.nil?
1256
+ return if body.is_a?(Hash) && JsonRpcHandler.valid_version?(body["jsonrpc"])
1257
+
1258
+ raise RequestHandlerError.new(
1259
+ 'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
1260
+ { method: method, params: params },
1261
+ error_type: :parse_error,
1262
+ )
1263
+ end
1264
+
1175
1265
  # SEP-1699 resumability: the server closed the SSE stream after a priming event
1176
1266
  # without delivering the response. Treat the graceful close like a network failure:
1177
1267
  # wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
@@ -1206,12 +1296,14 @@ module MCP
1206
1296
  read_timeout = remaining_reconnection_budget(deadline)
1207
1297
 
1208
1298
  reconnect_response = begin
1209
- client.get("") do |req|
1210
- req.headers.update(session_headers)
1211
- req.headers["Accept"] = SSE_ACCEPT_HEADER
1212
- req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
1213
- req.options.read_timeout = read_timeout
1214
- req.options.on_data = stream.on_data
1299
+ redacting_authorization_header do
1300
+ client.get("") do |req|
1301
+ req.headers.update(session_headers)
1302
+ req.headers["Accept"] = SSE_ACCEPT_HEADER
1303
+ req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
1304
+ req.options.read_timeout = read_timeout
1305
+ req.options.on_data = stream.on_data
1306
+ end
1215
1307
  end
1216
1308
  rescue StreamAbort
1217
1309
  # The awaited response arrived on the reconnected stream.
@@ -19,6 +19,9 @@ module MCP
19
19
  METADATA_DIAGNOSTIC_MAX_LENGTH = 128
20
20
  METADATA_URL_MAX_LENGTH = 2048
21
21
 
22
+ # RFC 6749 scope-token: visible ASCII except space, double quote, and backslash.
23
+ SCOPE_TOKEN_FORMAT = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/.freeze
24
+
22
25
  # Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
23
26
  # so a provider naming one of these is refused rather than left believing its value was sent.
24
27
  RESERVED_TOKEN_REQUEST_PARAMS = [
@@ -264,12 +267,14 @@ module MCP
264
267
 
265
268
  ensure_pkce_supported!(as_metadata)
266
269
 
267
- effective_scope = resolve_scope(scope: scope, prm: prm)
270
+ effective_scope = resolve_scope(scope: scope, prm: prm, select_prm_scope: true)
268
271
  effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
272
+ endpoint_uri, endpoint_params = authorization_endpoint_parameters(as_metadata: as_metadata)
273
+ request_scope = authorization_request_scope(scope: effective_scope, endpoint_params: endpoint_params)
269
274
 
270
275
  # Asked before registering, not after: a refusal must not leave this client registered at an authorization server
271
- # the embedding application has just rejected.
272
- authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
276
+ # the embedding application has just rejected. Use the scopes that will actually reach the browser URL.
277
+ authorize_request!(as_metadata: as_metadata, scope: request_scope, server_url: server_url, resource: resource)
273
278
 
274
279
  client_info = ensure_client_registered(as_metadata: as_metadata)
275
280
 
@@ -277,7 +282,8 @@ module MCP
277
282
  state = SecureRandom.urlsafe_base64(32)
278
283
 
279
284
  authorization_url = build_authorization_url(
280
- as_metadata: as_metadata,
285
+ endpoint_uri: endpoint_uri,
286
+ endpoint_params: endpoint_params,
281
287
  client_id: client_info_required_value(client_info, "client_id"),
282
288
  scope: effective_scope,
283
289
  state: state,
@@ -850,16 +856,16 @@ module MCP
850
856
  # Hands the embedding application the authorization server and the scopes that are about to be requested,
851
857
  # and abandons the flow when it refuses them.
852
858
  #
853
- # Both values are chosen by the MCP server: it names its own authorization server in Protected
854
- # Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge.
859
+ # The MCP server names its authorization server in Protected Resource Metadata and states scopes
860
+ # in `scopes_supported` or the `WWW-Authenticate` challenge. Authorization-code policy also sees
861
+ # any prefilled endpoint scope that will survive URL assembly.
855
862
  # Neither the specification nor any MCP SDK binds that choice to the server's own identity,
856
863
  # and validating that a token was issued for the intended audience is a responsibility the specification
857
864
  # places on MCP servers rather than on clients.
858
865
  # A host that knows which providers its user deals with can apply that knowledge here.
859
866
  #
860
- # The scopes are passed on unchanged whatever the host decides, because the specification requires
861
- # a client to treat the challenged scopes as authoritative for the operation; the choice offered is
862
- # to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
867
+ # Challenged scopes are authoritative for the operation and bypass the PRM scope selector.
868
+ # The validator can accept or refuse them, not quietly request fewer scopes.
863
869
  #
864
870
  # Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
865
871
  # this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
@@ -1302,17 +1308,18 @@ module MCP
1302
1308
  AuthorizationError.new(message, error: error, error_description: description)
1303
1309
  end
1304
1310
 
1305
- # Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
1306
- # prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
1307
- # from the Protected Resource Metadata, and falls back to a provider-supplied
1308
- # scope only if both are absent. The provider-supplied scope must not pre-empt
1309
- # a server-advertised one.
1310
- def resolve_scope(scope:, prm:)
1311
+ # MCP scope selection prefers the challenge, then PRM `scopes_supported`, then the provider's fallback.
1312
+ # Authorization-code clients may narrow only the PRM default, before `offline_access` augmentation.
1313
+ def resolve_scope(scope:, prm:, select_prm_scope: false)
1311
1314
  return scope if scope && !scope.empty?
1312
1315
 
1313
1316
  # `prm` is nil on the legacy path, where nothing advertises scopes.
1314
1317
  supported = prm && prm["scopes_supported"]
1315
- return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
1318
+ if supported.is_a?(Array) && !supported.empty?
1319
+ return select_prm_scopes(supported) if select_prm_scope
1320
+
1321
+ return supported.join(" ")
1322
+ end
1316
1323
 
1317
1324
  return @provider.scope if @provider.scope && !@provider.scope.empty?
1318
1325
 
@@ -1334,7 +1341,8 @@ module MCP
1334
1341
  # the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
1335
1342
  # own request consistent with the AS's advertisement.
1336
1343
  #
1337
- # Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
1344
+ # Returns `nil` when empty so the URL builder adds no flow-owned scope parameter; a prefilled scope
1345
+ # can still survive and is checked by the authorization-request validator.
1338
1346
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
1339
1347
  def normalize_offline_access_scope(scope, as_metadata:)
1340
1348
  scopes = scope.to_s.split
@@ -1354,6 +1362,28 @@ module MCP
1354
1362
  supported.is_a?(Array) && supported.include?("offline_access")
1355
1363
  end
1356
1364
 
1365
+ # Selects a subset of the PRM default without changing challenges, provider fallback, or refresh policy.
1366
+ def select_prm_scopes(scopes)
1367
+ selector = @provider.scope_selector if @provider.respond_to?(:scope_selector)
1368
+ return scopes.join(" ") unless selector
1369
+
1370
+ unless scopes.all? { |token| token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) }
1371
+ raise AuthorizationError, "Protected Resource Metadata `scopes_supported` contains invalid OAuth scope tokens."
1372
+ end
1373
+
1374
+ candidates = scopes.map { |token| token.dup.freeze }.freeze
1375
+ candidate_index = candidates.to_h { |token| [token, true] }
1376
+ selected = selector.call(candidates)
1377
+ valid = selected.is_a?(Array) && selected.all? do |token|
1378
+ token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) && candidate_index.key?(token)
1379
+ end
1380
+ unless valid
1381
+ raise ArgumentError, "scope_selector must return an Array containing only scopes from PRM scopes_supported."
1382
+ end
1383
+
1384
+ selected.empty? ? nil : selected.join(" ")
1385
+ end
1386
+
1357
1387
  def wants_refresh_token?
1358
1388
  metadata = @provider.client_metadata
1359
1389
  grant_types = metadata[:grant_types] || metadata["grant_types"]
@@ -1398,7 +1428,7 @@ module MCP
1398
1428
  @provider.client_id_metadata_document_url
1399
1429
  end
1400
1430
 
1401
- def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
1431
+ def authorization_endpoint_parameters(as_metadata:)
1402
1432
  authorization_endpoint = as_metadata["authorization_endpoint"]
1403
1433
  unless authorization_endpoint
1404
1434
  raise AuthorizationError,
@@ -1412,6 +1442,23 @@ module MCP
1412
1442
  "Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
1413
1443
  end
1414
1444
 
1445
+ [uri, URI.decode_www_form(uri.query.to_s)]
1446
+ end
1447
+
1448
+ # A flow scope replaces every endpoint scope; otherwise a single prefilled value survives.
1449
+ # Decode once before policy approval and reuse those parameters when building the URL.
1450
+ def authorization_request_scope(scope:, endpoint_params:)
1451
+ return scope if scope
1452
+
1453
+ endpoint_scopes = endpoint_params.filter_map { |name, value| value if name == "scope" }
1454
+ if endpoint_scopes.length > 1
1455
+ raise AuthorizationError, "Authorization endpoint contains repeated `scope` parameters."
1456
+ end
1457
+
1458
+ endpoint_scopes.first
1459
+ end
1460
+
1461
+ def build_authorization_url(endpoint_uri:, endpoint_params:, client_id:, scope:, state:, code_challenge:, resource:)
1415
1462
  # A parameter the flow sets replaces any of the same name the endpoint URL already carries.
1416
1463
  # RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is
1417
1464
  # its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak
@@ -1433,10 +1480,10 @@ module MCP
1433
1480
  own_params << ["resource", resource] if resource
1434
1481
  dropped_names = own_params.map(&:first) + ["request", "request_uri"]
1435
1482
 
1436
- params = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) }
1437
- uri.query = URI.encode_www_form(params + own_params)
1483
+ params = endpoint_params.reject { |name, _value| dropped_names.include?(name) }
1484
+ endpoint_uri.query = URI.encode_www_form(params + own_params)
1438
1485
 
1439
- uri
1486
+ endpoint_uri
1440
1487
  end
1441
1488
 
1442
1489
  def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:, redirect_uri: @provider.redirect_uri)
@@ -37,6 +37,10 @@ module MCP
37
37
  # `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
38
38
  # - `scope` - String of space-separated scopes to request when the server's
39
39
  # `WWW-Authenticate` does not specify one.
40
+ # - `scope_selector` - Callable receiving a read-only Array of PRM `scopes_supported` tokens when
41
+ # those defaults are selected without a challenged scope. Return an Array containing a subset;
42
+ # `[]` requests none of the PRM defaults. Challenged scopes and provider fallback bypass it.
43
+ # The existing `offline_access` policy runs afterward; endpoint query parameters are unchanged.
40
44
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
41
45
  # `client_information`, and `save_client_information(info)`. Defaults to
42
46
  # an `InMemoryStorage`. Persisted `client_information` is stamped with
@@ -108,6 +112,7 @@ module MCP
108
112
  attr_reader :client_metadata,
109
113
  :redirect_uri,
110
114
  :scope,
115
+ :scope_selector,
111
116
  :storage,
112
117
  :redirect_handler,
113
118
  :callback_handler,
@@ -120,6 +125,7 @@ module MCP
120
125
  redirect_handler:,
121
126
  callback_handler: nil,
122
127
  scope: nil,
128
+ scope_selector: nil,
123
129
  storage: nil,
124
130
  client_id_metadata_document_url: nil,
125
131
  authorization_request_validator: nil,
@@ -147,6 +153,10 @@ module MCP
147
153
  "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
148
154
  end
149
155
 
156
+ unless scope_selector.nil? || scope_selector.respond_to?(:call)
157
+ raise ArgumentError, "scope_selector must respond to call (got #{scope_selector.class})."
158
+ end
159
+
150
160
  http_client_customizer = validated_http_client_customizer(http_client_customizer)
151
161
 
152
162
  unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
@@ -170,6 +180,7 @@ module MCP
170
180
  @redirect_handler = redirect_handler
171
181
  @callback_handler = callback_handler
172
182
  @scope = scope
183
+ @scope_selector = scope_selector
173
184
  @storage = storage
174
185
  @client_id_metadata_document_url = client_id_metadata_document_url
175
186
  @authorization_request_validator = authorization_request_validator
@@ -442,17 +442,22 @@ module MCP
442
442
  # other server-to-client requests over stdio stay unsupported as documented,
443
443
  # and notifications carry no id.
444
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"])
445
+ # A JSON-RPC id is a String or a Number, and `jsonrpc` is "2.0"; the reference SDKs reject
446
+ # other shapes at schema validation, so a ping carrying one is skipped rather than echoed back.
447
+ answer_ping(parsed) if answerable_ping?(parsed)
448
448
  next
449
449
  end
450
450
 
451
451
  # A JSON-RPC message is an object; skip a non-object frame (array or scalar)
452
452
  # the same way as a frame without an id.
453
453
  next unless parsed.is_a?(Hash) && parsed.key?("id")
454
+ next unless parsed["id"] == request_id
454
455
 
455
- return parsed if parsed["id"] == request_id
456
+ # Nothing else will answer the awaited request, so a response that is not JSON-RPC 2.0 fails it
457
+ # instead of being skipped: without a `read_timeout`, skipping would wait forever.
458
+ raise_invalid_response!(method, params) unless JsonRpcHandler.valid_version?(parsed["jsonrpc"])
459
+
460
+ return parsed
456
461
  end
457
462
  rescue JSON::ParserError => e
458
463
  raise RequestHandlerError.new(
@@ -475,6 +480,10 @@ module MCP
475
480
  # surface as an error of the unrelated request whose response the loop is reading.
476
481
  end
477
482
 
483
+ def answerable_ping?(parsed)
484
+ parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
485
+ end
486
+
478
487
  def json_rpc_id?(id)
479
488
  id.is_a?(String) || id.is_a?(Numeric)
480
489
  end
@@ -532,6 +541,16 @@ module MCP
532
541
  error_type: :internal_error,
533
542
  )
534
543
  end
544
+
545
+ # The line framing is intact, so the transport stays usable; the received value is left out of
546
+ # the message because it can be any JSON value.
547
+ def raise_invalid_response!(method, params)
548
+ raise RequestHandlerError.new(
549
+ 'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
550
+ { method: method, params: params },
551
+ error_type: :internal_error,
552
+ )
553
+ end
535
554
  end
536
555
  end
537
556
  end