mcp 1.7.0 → 1.8.2

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.
data/lib/mcp/server.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "protocol_deprecations"
13
13
  require_relative "server_context"
14
14
  require_relative "server/capabilities"
15
15
  require_relative "server/input_required_result"
16
+ require_relative "server/oauth"
16
17
  require_relative "server/pagination"
17
18
  require_relative "server/pending_response"
18
19
  require_relative "server/request_state_security"
@@ -270,6 +271,9 @@ module MCP
270
271
  Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
271
272
  Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
272
273
  }
274
+ # The protocol's own request methods, as opposed to the notifications above and to custom methods
275
+ # registered later: these need a request id to be run.
276
+ @protocol_request_methods = @handlers.keys.reject { |name| Methods.notification?(name) }.freeze
273
277
  @transport = transport
274
278
  end
275
279
 
@@ -279,10 +283,13 @@ module MCP
279
283
  # @param session [ServerSession, nil] Per-connection session. Passed by
280
284
  # `ServerSession#handle` for session-scoped notification delivery.
281
285
  # When `nil`, progress and logging notifications from tool handlers are silently skipped.
286
+ # @param auth_info [Server::OAuth::AccessToken, nil] The verified access token
287
+ # for the current request, passed by the transport layer and exposed to
288
+ # handlers as `server_context.auth_info`.
282
289
  # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
283
- def handle(request, session: nil)
284
- JsonRpcHandler.handle(request) do |method, request_id|
285
- handle_request(request, method, session: session, related_request_id: request_id)
290
+ def handle(request, session: nil, auth_info: nil)
291
+ JsonRpcHandler.handle(request, id_validation_pattern: nil) do |method, request_id|
292
+ handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
286
293
  end
287
294
  end
288
295
 
@@ -292,10 +299,13 @@ module MCP
292
299
  # @param session [ServerSession, nil] Per-connection session. Passed by
293
300
  # `ServerSession#handle_json` for session-scoped notification delivery.
294
301
  # When `nil`, progress and logging notifications from tool handlers are silently skipped.
302
+ # @param auth_info [Server::OAuth::AccessToken, nil] The verified access token
303
+ # for the current request, as in `handle`.
295
304
  # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
296
- def handle_json(request, session: nil)
297
- JsonRpcHandler.handle_json(request) do |method, request_id|
298
- handle_request(request, method, session: session, related_request_id: request_id)
305
+ def handle_json(request, session: nil, auth_info: nil)
306
+ on_generate_error = ->(error) { report_exception(error, { error: "Failed to generate response" }) }
307
+ JsonRpcHandler.handle_json(request, id_validation_pattern: nil, on_generate_error: on_generate_error) do |method, request_id|
308
+ handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
299
309
  end
300
310
  end
301
311
 
@@ -491,6 +501,36 @@ module MCP
491
501
  @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
492
502
  end
493
503
 
504
+ # Returns the distinct members of `uris` that the `resources_subscribe_handler` accepts, calling it once per URI
505
+ # with `{ uri: }`, plus `_meta` when given. `subscriptions/listen` takes the place of `resources/subscribe`
506
+ # in the 2026-07-28 lifecycle, so the handler keeps deciding which resources a client may follow there too.
507
+ # A URI the handler raises for is reported and left out, as the spec lets the acknowledgement honor a subset of
508
+ # what was requested; any return value accepts the URI and is otherwise discarded. The `server_context` has no session,
509
+ # related request id, or cancellation. Public only so that `StreamableHTTPTransport` can call it.
510
+ #
511
+ # @api private
512
+ # @param uris [Array<String>] The `resourceSubscriptions` of a `subscriptions/listen` request.
513
+ # @param meta [Hash, nil] The `_meta` of that request.
514
+ # @param envelope [MCP::RequestEnvelope, nil] The envelope of that request.
515
+ # @param auth_info [MCP::Server::OAuth::AccessToken, nil] The token that authenticated that request.
516
+ # @return [Array<String>]
517
+ def subscribable_resource_uris(uris, meta: nil, envelope: nil, auth_info: nil)
518
+ handler = @handlers[Methods::RESOURCES_SUBSCRIBE]
519
+
520
+ uris.uniq.select do |uri|
521
+ params = { uri: uri }
522
+ params[:_meta] = meta if meta
523
+ dispatch_optional_context_handler(handler, params, envelope: envelope, auth_info: auth_info)
524
+ true
525
+ rescue RequestHandlerError => e
526
+ report_exception(e.original_error || e, { request: params })
527
+ false
528
+ rescue => e
529
+ report_exception(e, { request: params })
530
+ false
531
+ end
532
+ end
533
+
494
534
  def build_sampling_params(
495
535
  capabilities,
496
536
  messages:,
@@ -600,7 +640,7 @@ module MCP
600
640
  end
601
641
  end
602
642
 
603
- def handle_request(request, method, session: nil, related_request_id: nil)
643
+ def handle_request(request, method, session: nil, related_request_id: nil, auth_info: nil)
604
644
  # A well-formed notification carries no JSON-RPC id and receives no response.
605
645
  # If a client erroneously sends a notification-only method with an id, the message
606
646
  # is framed as a request; since notification methods have no request handler,
@@ -608,6 +648,12 @@ module MCP
608
648
  # the TypeScript and Python SDKs rather than emitting a spurious `result: null`.
609
649
  return if Methods.notification?(method) && !related_request_id.nil?
610
650
 
651
+ # The mirror case: a request method of the protocol sent without an id is framed as a notification,
652
+ # whose result the sender can never receive, so it is not run either; the TypeScript and Python servers
653
+ # drop such a message the same way, as a notification they have no handler for. A custom method keeps
654
+ # running without an id, since `define_custom_method` serves notifications too.
655
+ return if related_request_id.nil? && @protocol_request_methods.include?(method)
656
+
611
657
  # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
612
658
  # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
613
659
  # and rescue blocks below.
@@ -639,10 +685,18 @@ module MCP
639
685
  begin
640
686
  Methods.ensure_capability!(method, capabilities)
641
687
  rescue Methods::MissingRequiredCapabilityError => e
642
- # Re-raise through RequestHandlerError, the one channel whose message is
643
- # deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError`
644
- # no longer echoes exception messages into the error `data` member (CWE-209).
645
- raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e)
688
+ # A method of a capability the server does not declare is one the server does not implement, so it is answered as Method not found,
689
+ # which the TypeScript and Python servers return for a handler they never registered, and which the Streamable HTTP transport maps to
690
+ # 404 on the modern lifecycle. Re-raised through RequestHandlerError, the one channel whose message is deliberately surfaced to clients:
691
+ # `JsonRpcHandler`'s blind `rescue StandardError` no longer echoes exception messages into the error `data` member (CWE-209).
692
+ raise RequestHandlerError.new(
693
+ "Method not found",
694
+ request,
695
+ error_type: :method_not_found,
696
+ error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
697
+ error_data: e.message,
698
+ original_error: e,
699
+ )
646
700
  end
647
701
 
648
702
  # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
@@ -691,25 +745,25 @@ module MCP
691
745
  when Methods::INITIALIZE
692
746
  init(params, session: session)
693
747
  when Methods::RESOURCES_READ
694
- contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
748
+ contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
695
749
 
696
750
  # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
697
751
  contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
698
752
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
699
753
  validate_resource_subscription_params!(params)
700
- handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
754
+ handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
701
755
 
702
756
  subscription_result(handler_result)
703
757
  when Methods::TOOLS_CALL
704
- call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
758
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
705
759
  when Methods::PROMPTS_GET
706
- get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
760
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
707
761
  when Methods::COMPLETION_COMPLETE
708
- complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
762
+ complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
709
763
  when Methods::LOGGING_SET_LEVEL
710
764
  configure_logging_level(params, session: session)
711
765
  else
712
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
766
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
713
767
  end
714
768
  client = session&.client || @client
715
769
  add_instrumentation_data(client: client) if client
@@ -760,6 +814,15 @@ module MCP
760
814
  rescue CancelledError => e
761
815
  add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
762
816
  next JsonRpcHandler::NO_RESPONSE
817
+ rescue OAuth::InsufficientScopeError => e
818
+ # A handler's `require_scopes!` failure is a client authorization error, not a server fault:
819
+ # surface it as an invalid-request JSON-RPC error whose message names the missing scopes.
820
+ # HTTP-level 403 challenges remain the transport `required_scopes` gate's job.
821
+ report_exception(e, { request: request })
822
+ add_instrumentation_data(error: :insufficient_scope)
823
+ converted = RequestHandlerError.new(e.message, request, error_type: :invalid_request, original_error: e)
824
+ reported_exception = converted
825
+ raise converted
763
826
  rescue RequestHandlerError => e
764
827
  report_exception(e.original_error || e, { request: request })
765
828
  add_instrumentation_data(error: e.error_type)
@@ -1116,7 +1179,7 @@ module MCP
1116
1179
 
1117
1180
  {
1118
1181
  protocolVersion: negotiated_version,
1119
- capabilities: capabilities,
1182
+ capabilities: handshake_capabilities,
1120
1183
  serverInfo: info,
1121
1184
  instructions: response_instructions,
1122
1185
  }.compact
@@ -1193,6 +1256,19 @@ module MCP
1193
1256
  def discover_capabilities
1194
1257
  return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
1195
1258
 
1259
+ without_notification_flags(capabilities)
1260
+ end
1261
+
1262
+ # Capabilities as advertised by `initialize`. A transport that delivers no notifications to its sessions
1263
+ # (stateless Streamable HTTP) cannot keep the promise the `listChanged` and `subscribe` flags make, so they are
1264
+ # left out, as `server/discover` leaves them out when `subscriptions/listen` is not served.
1265
+ def handshake_capabilities
1266
+ return capabilities unless @transport.respond_to?(:delivers_notifications?) && !@transport.delivers_notifications?
1267
+
1268
+ without_notification_flags(capabilities)
1269
+ end
1270
+
1271
+ def without_notification_flags(capabilities)
1196
1272
  capabilities.each_with_object({}) do |(name, value), stripped|
1197
1273
  stripped[name] = if value.is_a?(Hash)
1198
1274
  value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) }
@@ -1224,7 +1300,7 @@ module MCP
1224
1300
  apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
1225
1301
  end
1226
1302
 
1227
- def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1303
+ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1228
1304
  tool_name = request[:name]
1229
1305
 
1230
1306
  tool = tools[tool_name]
@@ -1259,12 +1335,13 @@ module MCP
1259
1335
  response = call_tool_with_args(
1260
1336
  tool,
1261
1337
  arguments,
1262
- server_context_with_meta(request),
1338
+ server_context_with_meta(request, auth_info: auth_info),
1263
1339
  progress_token: progress_token,
1264
1340
  session: session,
1265
1341
  related_request_id: related_request_id,
1266
1342
  cancellation: cancellation,
1267
1343
  envelope: envelope,
1344
+ auth_info: auth_info,
1268
1345
  retry_fields: mrtr_retry_fields(request),
1269
1346
  )
1270
1347
  # An SEP-2322 `input_required` result is not a tool result: output schema
@@ -1278,9 +1355,11 @@ module MCP
1278
1355
  result,
1279
1356
  content_provided: response.respond_to?(:content_provided?) && response.content_provided?,
1280
1357
  )
1281
- rescue RequestHandlerError, CancelledError
1358
+ rescue RequestHandlerError, CancelledError, OAuth::InsufficientScopeError
1282
1359
  # CancelledError is intentionally not wrapped so `handle_request` can turn it into
1283
- # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
1360
+ # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec, and
1361
+ # `OAuth::InsufficientScopeError` so `require_scopes!` failures keep their
1362
+ # scope-naming message instead of the generic internal-error shape.
1284
1363
  raise
1285
1364
  rescue => e
1286
1365
  # `e.message` is deliberately not included: it can carry internals (class, method
@@ -1300,7 +1379,7 @@ module MCP
1300
1379
  apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
1301
1380
  end
1302
1381
 
1303
- def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1382
+ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1304
1383
  prompt_name = request[:name]
1305
1384
  prompt = @prompts[prompt_name]
1306
1385
  unless prompt
@@ -1328,6 +1407,7 @@ module MCP
1328
1407
  related_request_id: related_request_id,
1329
1408
  cancellation: cancellation,
1330
1409
  envelope: envelope,
1410
+ auth_info: auth_info,
1331
1411
  )
1332
1412
 
1333
1413
  call_prompt_template_with_args(prompt, prompt_args, server_context)
@@ -1440,7 +1520,7 @@ module MCP
1440
1520
  { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
1441
1521
  end
1442
1522
 
1443
- def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1523
+ def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1444
1524
  validate_completion_params!(params)
1445
1525
 
1446
1526
  result = dispatch_optional_context_handler(
@@ -1450,6 +1530,7 @@ module MCP
1450
1530
  related_request_id: related_request_id,
1451
1531
  cancellation: cancellation,
1452
1532
  envelope: envelope,
1533
+ auth_info: auth_info,
1453
1534
  )
1454
1535
 
1455
1536
  normalize_completion_result(result)
@@ -1458,7 +1539,7 @@ module MCP
1458
1539
  # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
1459
1540
  # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
1460
1541
  # `server_context.raise_if_cancelled!`.
1461
- def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1542
+ def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1462
1543
  dispatch_optional_context_handler(
1463
1544
  @handlers[Methods::RESOURCES_READ],
1464
1545
  request,
@@ -1466,6 +1547,7 @@ module MCP
1466
1547
  related_request_id: related_request_id,
1467
1548
  cancellation: cancellation,
1468
1549
  envelope: envelope,
1550
+ auth_info: auth_info,
1469
1551
  )
1470
1552
  end
1471
1553
 
@@ -1473,7 +1555,7 @@ module MCP
1473
1555
  # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
1474
1556
  # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
1475
1557
  # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
1476
- def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1558
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1477
1559
  return handler.call(params) unless handler_declares_server_context?(handler)
1478
1560
 
1479
1561
  server_context = build_server_context(
@@ -1482,6 +1564,7 @@ module MCP
1482
1564
  related_request_id: related_request_id,
1483
1565
  cancellation: cancellation,
1484
1566
  envelope: envelope,
1567
+ auth_info: auth_info,
1485
1568
  )
1486
1569
  handler.call(params, server_context: server_context)
1487
1570
  end
@@ -1506,13 +1589,13 @@ module MCP
1506
1589
 
1507
1590
  # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
1508
1591
  # (progress, cancellation, nested server-to-client requests).
1509
- def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
1592
+ def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil, auth_info: nil)
1510
1593
  meta_source = request.is_a?(Hash) ? request : {}
1511
1594
  progress_token = meta_source.dig(:_meta, :progressToken)
1512
1595
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
1513
1596
  retry_fields = mrtr_retry_fields(meta_source)
1514
1597
  ServerContext.new(
1515
- server_context_with_meta(meta_source),
1598
+ server_context_with_meta(meta_source, auth_info: auth_info),
1516
1599
  progress: progress,
1517
1600
  notification_target: session,
1518
1601
  related_request_id: related_request_id,
@@ -1520,6 +1603,7 @@ module MCP
1520
1603
  envelope: envelope,
1521
1604
  input_responses: retry_fields[:input_responses],
1522
1605
  request_state: retry_fields[:request_state],
1606
+ auth_info: auth_info,
1523
1607
  )
1524
1608
  end
1525
1609
 
@@ -1579,7 +1663,7 @@ module MCP
1579
1663
  end
1580
1664
  end
1581
1665
 
1582
- def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, retry_fields: nil)
1666
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil, retry_fields: nil)
1583
1667
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1584
1668
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1585
1669
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
@@ -1597,6 +1681,7 @@ module MCP
1597
1681
  envelope: envelope,
1598
1682
  input_responses: retry_fields&.fetch(:input_responses, nil),
1599
1683
  request_state: retry_fields&.fetch(:request_state, nil),
1684
+ auth_info: auth_info,
1600
1685
  )
1601
1686
  tool.call(**args, server_context: server_context)
1602
1687
  else
@@ -1614,15 +1699,18 @@ module MCP
1614
1699
  raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
1615
1700
  end
1616
1701
 
1617
- def server_context_with_meta(request)
1702
+ def server_context_with_meta(request, auth_info: nil)
1618
1703
  meta = request[:_meta]
1619
- if meta && server_context.is_a?(Hash)
1620
- context = server_context.dup
1621
- context[:_meta] = meta
1704
+ return server_context if meta.nil? && auth_info.nil?
1705
+
1706
+ if server_context.is_a?(Hash) || server_context.nil?
1707
+ context = (server_context || {}).dup
1708
+ context[:_meta] = meta if meta
1709
+ context[:auth_info] = auth_info if auth_info
1622
1710
  context
1623
- elsif meta && server_context.nil?
1624
- { _meta: meta }
1625
1711
  else
1712
+ # A custom (non-Hash) user context is passed through untouched; handlers on that path read
1713
+ # the token via `server_context.auth_info` instead.
1626
1714
  server_context
1627
1715
  end
1628
1716
  end
@@ -15,8 +15,11 @@ module MCP
15
15
  # (the same access model as the envelope readers).
16
16
  attr_reader :input_responses, :request_state
17
17
 
18
- def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil,
19
- input_responses: nil, request_state: nil)
18
+ # The verified `MCP::Server::OAuth::AccessToken` for the current request, or nil when the request was not bearer-authenticated.
19
+ # This reader is the canonical accessor; when the underlying context is a Hash the same value is also reachable as `server_context[:auth_info]`.
20
+ attr_reader :auth_info
21
+
22
+ def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil, input_responses: nil, request_state: nil, auth_info: nil)
20
23
  @context = context
21
24
  @progress = progress
22
25
  @notification_target = notification_target
@@ -25,6 +28,7 @@ module MCP
25
28
  @envelope = envelope
26
29
  @input_responses = input_responses
27
30
  @request_state = request_state
31
+ @auth_info = auth_info
28
32
  end
29
33
 
30
34
  # Reads one entry of {#input_responses} by its `inputRequests` key, tolerating symbol or string keys.
@@ -89,6 +93,26 @@ module MCP
89
93
  raise Server::MissingRequiredClientCapabilityError, required
90
94
  end
91
95
 
96
+ # Whether the current request was bearer-authenticated.
97
+ def authenticated?
98
+ !@auth_info.nil?
99
+ end
100
+
101
+ # Guards the current operation on OAuth scopes the token must carry, for authorization decisions finer-grained than
102
+ # the transport-wide `required_scopes`. Raises `Server::OAuth::InsufficientScopeError`, which surfaces as
103
+ # a JSON-RPC invalid-request error naming the missing scopes. HTTP-level 403 step-up challenges remain the job of
104
+ # the transport's `required_scopes` gate, matching how the Python and TypeScript SDKs split endpoint-level challenges
105
+ # from in-handler authorization. The transport's `scope_matcher:` applies here as well: the authenticator attaches it
106
+ # to the token it hands over, so a scope hierarchy is judged the same way at the gate and in handlers.
107
+ def require_scopes!(*scopes)
108
+ raise ArgumentError, "at least one scope is required" if scopes.empty?
109
+
110
+ missing_scopes = scopes.reject { |scope| @auth_info&.scope?(scope) }
111
+ return if missing_scopes.empty?
112
+
113
+ raise Server::OAuth::InsufficientScopeError.new("Token is missing required scopes: #{missing_scopes.join(", ")}", required_scopes: scopes)
114
+ end
115
+
92
116
  # Reports progress for the current tool operation.
93
117
  # The notification is automatically scoped to the originating session.
94
118
  #
@@ -120,12 +120,22 @@ module MCP
120
120
  MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: request_id })
121
121
  end
122
122
 
123
- def handle(request)
124
- @server.handle(request, session: self)
123
+ # Accepts the request either as a positional Hash or as bare keyword arguments (`session.handle(jsonrpc: "2.0", ...)`),
124
+ # a calling style that predates this method having keyword parameters. `auth_info:` is honored only alongside a positional request:
125
+ # in the bare-keyword style every keyword belongs to the request body, and the body is attacker-authored JSON,
126
+ # so a captured `auth_info` keyword is folded back into the request rather than trusted as a verified credential.
127
+ def handle(request = nil, auth_info: nil, **request_keywords)
128
+ if request.nil?
129
+ request = request_keywords
130
+ request[:auth_info] = auth_info unless auth_info.nil?
131
+ auth_info = nil
132
+ end
133
+
134
+ @server.handle(request, session: self, auth_info: auth_info)
125
135
  end
126
136
 
127
- def handle_json(request_json)
128
- @server.handle_json(request_json, session: self)
137
+ def handle_json(request_json, auth_info: nil)
138
+ @server.handle_json(request_json, session: self, auth_info: auth_info)
129
139
  end
130
140
 
131
141
  # Called by `Server#init` during the initialization handshake.
@@ -265,14 +275,14 @@ module MCP
265
275
  def notify_elicitation_complete(elicitation_id:)
266
276
  send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
267
277
  rescue => e
268
- @server.report_exception(e, notification: "elicitation_complete")
278
+ @server.configuration.exception_reporter.call(e, { notification: "elicitation_complete" })
269
279
  end
270
280
 
271
281
  # Sends a resource updated notification to this session only.
272
282
  def notify_resources_updated(uri:)
273
283
  send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
274
284
  rescue => e
275
- @server.report_exception(e, notification: "resources_updated")
285
+ @server.configuration.exception_reporter.call(e, { notification: "resources_updated" })
276
286
  end
277
287
 
278
288
  # Sends a progress notification to this session only.
@@ -286,7 +296,7 @@ module MCP
286
296
 
287
297
  send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
288
298
  rescue => e
289
- @server.report_exception(e, notification: "progress")
299
+ @server.configuration.exception_reporter.call(e, { notification: "progress" })
290
300
  end
291
301
 
292
302
  # Sends a log message notification to this session only.
@@ -310,7 +320,7 @@ module MCP
310
320
 
311
321
  send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
312
322
  rescue => e
313
- @server.report_exception(e, { notification: "log_message" })
323
+ @server.configuration.exception_reporter.call(e, { notification: "log_message" })
314
324
  end
315
325
 
316
326
  private
data/lib/mcp/transport.rb CHANGED
@@ -57,6 +57,14 @@ module MCP
57
57
  false
58
58
  end
59
59
 
60
+ # Whether the transport delivers notifications to a connected client outside the response to one of its
61
+ # requests (`notifications/*/list_changed`, `notifications/resources/updated`). `Server#init` strips the
62
+ # `listChanged`/`subscribe` capability flags when it does not, since the handshake would promise notifications
63
+ # the transport drops.
64
+ def delivers_notifications?
65
+ true
66
+ end
67
+
60
68
  private
61
69
 
62
70
  def generate_request_id
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.7.0"
4
+ VERSION = "1.8.2"
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.7.0
4
+ version: 1.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -85,6 +85,17 @@ files:
85
85
  - lib/mcp/server.rb
86
86
  - lib/mcp/server/capabilities.rb
87
87
  - lib/mcp/server/input_required_result.rb
88
+ - lib/mcp/server/oauth.rb
89
+ - lib/mcp/server/oauth/access_token.rb
90
+ - lib/mcp/server/oauth/authenticator.rb
91
+ - lib/mcp/server/oauth/challenge.rb
92
+ - lib/mcp/server/oauth/errors.rb
93
+ - lib/mcp/server/oauth/introspection_verifier.rb
94
+ - lib/mcp/server/oauth/jwt_verifier.rb
95
+ - lib/mcp/server/oauth/middleware.rb
96
+ - lib/mcp/server/oauth/protected_resource_metadata.rb
97
+ - lib/mcp/server/oauth/protected_resource_metadata_middleware.rb
98
+ - lib/mcp/server/oauth/token_verifier.rb
88
99
  - lib/mcp/server/pagination.rb
89
100
  - lib/mcp/server/pending_response.rb
90
101
  - lib/mcp/server/request_state_security.rb
@@ -108,7 +119,7 @@ licenses:
108
119
  - Apache-2.0
109
120
  metadata:
110
121
  allowed_push_host: https://rubygems.org
111
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.7.0
122
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.8.2
112
123
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
113
124
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
114
125
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues