mcp 1.7.0 → 1.8.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: 87f6233cbd083a14b47183b21558ee995c06953ef5bb8d9ea568eca8e69103de
4
- data.tar.gz: 11697a1b6e71297211fac5ecf411ac381a064e66274ac9c9a9640087284859ad
3
+ metadata.gz: 3585a92ae036b5018c7edf6b4c77a4a5c07903084314459a58180df16d3f0e28
4
+ data.tar.gz: d543ab701b3589e5728dba998e9b7d66b52baabfa4e9cf272834024bc57340a0
5
5
  SHA512:
6
- metadata.gz: ec9899cc307eb88706aded076b0e7be9bb2bdbf2fbf52c3d8f8f7a7ebe05dbb1da802ebfc8ea9bc1d5ecc18c361a2249488815c60175650203815a084aad9833
7
- data.tar.gz: c43d95b541108f64ac594567e44f3de9e103f4f52c2c122ef0f0d90dcaa52b391b419c536cf4653c784299324a71c23c9bb7da9a7c4f9f69db87c2ab1d730871
6
+ metadata.gz: 2d8cdd94c440d86ed5c317cc4bd94b21279ea613be75956bc49de0ddb008a0b574f3ee15751053be78c249d420709a168f0810674dbeeea34d9cd359dd9ac2f0
7
+ data.tar.gz: 9f131703b9db0f7fdfbc0a57ffe7b4132c522720bf841aaa4d70f20487644fe6ef428f89f4f39d4c1044312045553356fe87c3684f1deadc95dc81b45e441d42
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
@@ -202,9 +202,10 @@ module MCP
202
202
 
203
203
  if parsed.key?("result") || parsed.key?("error")
204
204
  @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.
205
+ elsif parsed["method"] && parsed.key?("id") && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
206
+ # A server-to-client request (e.g. `elicitation/create`) delivered on the stream while
207
+ # the original request is still pending. One that is not JSON-RPC 2.0 is dropped unanswered,
208
+ # as the TypeScript client drops it at schema validation.
208
209
  @on_request&.call(parsed)
209
210
  end
210
211
  end
@@ -399,6 +400,7 @@ module MCP
399
400
  )
400
401
  end
401
402
 
403
+ request_id = request[:id] || request["id"]
402
404
  method = request[:method] || request["method"]
403
405
  params = request[:params] || request["params"]
404
406
  oauth_retried = false
@@ -417,14 +419,20 @@ module MCP
417
419
  yield if block_given?
418
420
 
419
421
  response = begin
420
- client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
421
- req.options.on_data = stream.on_data
422
+ redacting_authorization_header do
423
+ client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
424
+ req.options.on_data = stream.on_data
425
+ end
422
426
  end
423
427
  rescue StreamAbort
424
428
  nil
425
429
  end
426
430
 
427
- body = resolve_response_body(stream, response, method, params)
431
+ body = resolve_response_body(stream, response, request_id, method, params)
432
+
433
+ # Every way a response arrives (JSON body, SSE event, resumed stream) ends here. Checked before
434
+ # anything is learned from the body, and only for a request: a notification awaits no response.
435
+ reject_invalid_response!(body, method, params) if request_id
428
436
 
429
437
  capture_session_info(method, response, body) if response
430
438
  capture_mcp_param_declarations(method, params, body)
@@ -522,7 +530,7 @@ module MCP
522
530
  def send_notification(notification:)
523
531
  method = notification[:method] || notification["method"]
524
532
 
525
- client.post("", notification, session_headers)
533
+ redacting_authorization_header { client.post("", notification, session_headers) }
526
534
  nil
527
535
  rescue Faraday::Error => e
528
536
  raise RequestHandlerError.new(
@@ -550,7 +558,7 @@ module MCP
550
558
  end
551
559
 
552
560
  begin
553
- client.delete("", nil, session_headers)
561
+ redacting_authorization_header { client.delete("", nil, session_headers) }
554
562
  rescue Faraday::ClientError => e
555
563
  raise unless [404, 405].include?(e.response&.dig(:status))
556
564
  ensure
@@ -1045,6 +1053,51 @@ module MCP
1045
1053
  [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
1046
1054
  end
1047
1055
 
1056
+ # Runs a request on the transport's connection and, when Faraday raises, replaces the value of the `Authorization` header
1057
+ # in the request headers the exception retains before it leaves the transport.
1058
+ # Faraday's `raise_error` middleware keeps a reference to `env.request_headers` in `Faraday::Error#response`
1059
+ # under `:request`, so the exception that becomes `RequestHandlerError#original_error` (and its `cause`) would otherwise print
1060
+ # the bearer token through `inspect`, which is what error reporters and log lines do with it. The request is complete by
1061
+ # the time the exception surfaces, and every request builds its own headers from the connection's defaults,
1062
+ # so the rewrite reaches neither those defaults nor a later request.
1063
+ def redacting_authorization_header
1064
+ yield
1065
+ rescue Faraday::Error => e
1066
+ redact_authorization_header!(e)
1067
+ raise
1068
+ end
1069
+
1070
+ # `raise_error` retains the request as a Hash under `:request`; a JSON middleware the customizer added raises
1071
+ # `Faraday::ParsingError` with the `Faraday::Response` itself, whose `env` holds the request headers.
1072
+ def redact_authorization_header!(error)
1073
+ response = error.response
1074
+
1075
+ if response.is_a?(Hash)
1076
+ request = response[:request]
1077
+ redact_authorization_header_in!(request, :headers) if request.is_a?(Hash)
1078
+ elsif response.respond_to?(:env) && response.env.respond_to?(:request_headers)
1079
+ redact_authorization_header_in!(response.env, :request_headers)
1080
+ end
1081
+ end
1082
+
1083
+ # The headers are a `Faraday::Utils::Headers`, which spells the name `Authorization` whatever the caller wrote,
1084
+ # unless a customizer middleware replaced them with a plain Hash or froze them: the name is matched regardless
1085
+ # of case so such a Hash is covered too, and a frozen object is swapped for a copy, so the redaction never
1086
+ # raises in place of the error it is redacting.
1087
+ def redact_authorization_header_in!(holder, key)
1088
+ headers = holder[key]
1089
+ return unless headers.is_a?(Hash)
1090
+
1091
+ names = headers.each_key.select { |name| name.to_s.casecmp?("authorization") }
1092
+ return if names.empty?
1093
+
1094
+ headers = holder[key] = headers.dup if headers.frozen?
1095
+
1096
+ names.each do |name|
1097
+ headers[name] = "[redacted]"
1098
+ end
1099
+ end
1100
+
1048
1101
  def require_faraday!
1049
1102
  require "faraday"
1050
1103
  rescue LoadError
@@ -1054,8 +1107,11 @@ module MCP
1054
1107
  end
1055
1108
 
1056
1109
  # 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)
1110
+ # (or was aborted because the response already arrived). A notification (no `request_id`) awaits
1111
+ # no response, so any success status acknowledges it, as it does for the TypeScript and Python clients,
1112
+ # which check the content type of a response only for a request; the spec has the server answer
1113
+ # a notification with 202, but a server answering with another 2xx is accepted as well.
1114
+ def resolve_response_body(stream, response, request_id, method, params)
1059
1115
  return stream.response if stream.response
1060
1116
 
1061
1117
  # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
@@ -1073,6 +1129,9 @@ module MCP
1073
1129
  return stream.response if stream.response
1074
1130
  end
1075
1131
 
1132
+ # A stream that ends without a response has acknowledged a notification; there is nothing to resume for.
1133
+ return if request_id.nil? && response.success?
1134
+
1076
1135
  # SEP-1699: a graceful close after a priming event (an event with an id) but before
1077
1136
  # the response means "reconnect via GET to resume".
1078
1137
  return await_response_after_disconnect(stream, method, params) if stream.primed?
@@ -1089,6 +1148,8 @@ module MCP
1089
1148
  # added by the Faraday customizer may have parsed it already.
1090
1149
  response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
1091
1150
  else
1151
+ return if request_id.nil? && response.success?
1152
+
1092
1153
  raise RequestHandlerError.new(
1093
1154
  "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
1094
1155
  { method: method, params: params },
@@ -1157,7 +1218,7 @@ module MCP
1157
1218
  end
1158
1219
 
1159
1220
  def send_client_response(response)
1160
- client.post("", response, session_headers)
1221
+ redacting_authorization_header { client.post("", response, session_headers) }
1161
1222
  end
1162
1223
 
1163
1224
  def parse_json_buffer(buffer, method, params)
@@ -1172,6 +1233,20 @@ module MCP
1172
1233
  )
1173
1234
  end
1174
1235
 
1236
+ # A response is a JSON object whose `jsonrpc` is exactly "2.0". No body at all (202, or an empty 200)
1237
+ # is not a message and is left to the caller as before; a JSON `null` body parses to the same `nil`.
1238
+ # The received value is left out of the message because it can be any JSON value.
1239
+ def reject_invalid_response!(body, method, params)
1240
+ return if body.nil?
1241
+ return if body.is_a?(Hash) && JsonRpcHandler.valid_version?(body["jsonrpc"])
1242
+
1243
+ raise RequestHandlerError.new(
1244
+ 'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
1245
+ { method: method, params: params },
1246
+ error_type: :parse_error,
1247
+ )
1248
+ end
1249
+
1175
1250
  # SEP-1699 resumability: the server closed the SSE stream after a priming event
1176
1251
  # without delivering the response. Treat the graceful close like a network failure:
1177
1252
  # wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
@@ -1206,12 +1281,14 @@ module MCP
1206
1281
  read_timeout = remaining_reconnection_budget(deadline)
1207
1282
 
1208
1283
  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
1284
+ redacting_authorization_header do
1285
+ client.get("") do |req|
1286
+ req.headers.update(session_headers)
1287
+ req.headers["Accept"] = SSE_ACCEPT_HEADER
1288
+ req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
1289
+ req.options.read_timeout = read_timeout
1290
+ req.options.on_data = stream.on_data
1291
+ end
1215
1292
  end
1216
1293
  rescue StreamAbort
1217
1294
  # 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
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ module OAuth
6
+ # The result of a successful token verification: the resource-server-side view of an access token.
7
+ # Instances are produced by a `TokenVerifier` and reach tool/prompt/resource handlers as `server_context.auth_info`.
8
+ #
9
+ # The field set is the union of what the official SDKs converged on (Python `AccessToken` and TypeScript `AuthInfo`):
10
+ #
11
+ # - `token` - the raw bearer token as presented by the client
12
+ # - `client_id` - the OAuth client the token was issued to
13
+ # - `scopes` - granted scopes as an array of strings
14
+ # - `expires_at` - expiry as an Integer unix timestamp, or nil when the token does not expire
15
+ # - `subject` - the end user (`sub` claim), when known
16
+ # - `issuer` - the authorization server that issued the token (`iss` claim)
17
+ # - `audience` - the raw `aud` value (String or Array), when known
18
+ # - `resource` - the canonical RFC 8707 resource identifier the verifier matched the token against,
19
+ # useful for tenancy checks inside handlers
20
+ # - `claims` - the full claim/introspection-response Hash for anything not covered by the named fields
21
+ class AccessToken
22
+ attr_reader :token, :client_id, :scopes, :expires_at, :subject, :issuer, :audience, :resource, :claims
23
+
24
+ def initialize(token:, client_id: nil, scopes: [], expires_at: nil, subject: nil, issuer: nil, audience: nil, resource: nil, claims: {})
25
+ @token = token
26
+ @client_id = client_id
27
+ @scopes = scopes
28
+ @expires_at = expires_at
29
+ @subject = subject
30
+ @issuer = issuer
31
+ @audience = audience
32
+ @resource = resource
33
+ @claims = claims
34
+ @scope_matcher = nil
35
+ end
36
+
37
+ # Per RFC 7519 Section 4.1.4, a token must not be accepted on or after its expiry time.
38
+ # Tokens without an expiry never count as expired here; verifiers that require an expiry must enforce that themselves
39
+ # (`JWTVerifier` does, the TypeScript SDK rejects such tokens outright, and Python accepts them like this class —
40
+ # the divergence is deliberate: opaque-token verifiers may have no expiry to report even though the authorization server enforces one).
41
+ def expired?(now: Time.now.to_i)
42
+ return false if expires_at.nil?
43
+
44
+ expires_at <= now
45
+ end
46
+
47
+ # Whether the token grants `scope`: exact membership by default. When the authenticator attached its `scope_matcher:`
48
+ # (see `with_scope_matcher`), that callable decides instead, so a hierarchical scope scheme is honored the same way
49
+ # at the endpoint gate and inside handlers via `require_scopes!`.
50
+ def scope?(scope)
51
+ return scopes.include?(scope.to_s) if @scope_matcher.nil?
52
+
53
+ !!@scope_matcher.call(scope.to_s, scopes)
54
+ end
55
+
56
+ # A copy of this token whose `scope?` consults `matcher`, a `(required_scope, granted_scopes) -> Boolean` callable.
57
+ # The receiver is left untouched; a nil matcher returns the receiver itself.
58
+ def with_scope_matcher(matcher)
59
+ return self if matcher.nil?
60
+
61
+ dup.tap { |copy| copy.scope_matcher = matcher }
62
+ end
63
+
64
+ # Mirrors `to_h`: the default `inspect` would print `@token`, leaking the credential into exception reports and debug output.
65
+ def inspect
66
+ "#<#{self.class.name} #{to_h.inspect}>"
67
+ end
68
+
69
+ # Omits `token` so the result is safe to log without leaking the credential.
70
+ def to_h
71
+ {
72
+ client_id: client_id,
73
+ scopes: scopes,
74
+ expires_at: expires_at,
75
+ subject: subject,
76
+ issuer: issuer,
77
+ audience: audience,
78
+ resource: resource,
79
+ claims: claims,
80
+ }.compact
81
+ end
82
+
83
+ protected
84
+
85
+ attr_writer :scope_matcher
86
+ end
87
+ end
88
+ end
89
+ end