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.
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"
@@ -279,10 +280,13 @@ module MCP
279
280
  # @param session [ServerSession, nil] Per-connection session. Passed by
280
281
  # `ServerSession#handle` for session-scoped notification delivery.
281
282
  # When `nil`, progress and logging notifications from tool handlers are silently skipped.
283
+ # @param auth_info [Server::OAuth::AccessToken, nil] The verified access token
284
+ # for the current request, passed by the transport layer and exposed to
285
+ # handlers as `server_context.auth_info`.
282
286
  # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
283
- def handle(request, session: nil)
287
+ def handle(request, session: nil, auth_info: nil)
284
288
  JsonRpcHandler.handle(request) do |method, request_id|
285
- handle_request(request, method, session: session, related_request_id: request_id)
289
+ handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
286
290
  end
287
291
  end
288
292
 
@@ -292,10 +296,12 @@ module MCP
292
296
  # @param session [ServerSession, nil] Per-connection session. Passed by
293
297
  # `ServerSession#handle_json` for session-scoped notification delivery.
294
298
  # When `nil`, progress and logging notifications from tool handlers are silently skipped.
299
+ # @param auth_info [Server::OAuth::AccessToken, nil] The verified access token
300
+ # for the current request, as in `handle`.
295
301
  # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
296
- def handle_json(request, session: nil)
302
+ def handle_json(request, session: nil, auth_info: nil)
297
303
  JsonRpcHandler.handle_json(request) do |method, request_id|
298
- handle_request(request, method, session: session, related_request_id: request_id)
304
+ handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
299
305
  end
300
306
  end
301
307
 
@@ -600,7 +606,7 @@ module MCP
600
606
  end
601
607
  end
602
608
 
603
- def handle_request(request, method, session: nil, related_request_id: nil)
609
+ def handle_request(request, method, session: nil, related_request_id: nil, auth_info: nil)
604
610
  # A well-formed notification carries no JSON-RPC id and receives no response.
605
611
  # If a client erroneously sends a notification-only method with an id, the message
606
612
  # is framed as a request; since notification methods have no request handler,
@@ -691,25 +697,25 @@ module MCP
691
697
  when Methods::INITIALIZE
692
698
  init(params, session: session)
693
699
  when Methods::RESOURCES_READ
694
- contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
700
+ contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
695
701
 
696
702
  # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
697
703
  contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
698
704
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
699
705
  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)
706
+ 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
707
 
702
708
  subscription_result(handler_result)
703
709
  when Methods::TOOLS_CALL
704
- call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
710
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
705
711
  when Methods::PROMPTS_GET
706
- get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
712
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
707
713
  when Methods::COMPLETION_COMPLETE
708
- complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
714
+ complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
709
715
  when Methods::LOGGING_SET_LEVEL
710
716
  configure_logging_level(params, session: session)
711
717
  else
712
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
718
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
713
719
  end
714
720
  client = session&.client || @client
715
721
  add_instrumentation_data(client: client) if client
@@ -760,6 +766,15 @@ module MCP
760
766
  rescue CancelledError => e
761
767
  add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
762
768
  next JsonRpcHandler::NO_RESPONSE
769
+ rescue OAuth::InsufficientScopeError => e
770
+ # A handler's `require_scopes!` failure is a client authorization error, not a server fault:
771
+ # surface it as an invalid-request JSON-RPC error whose message names the missing scopes.
772
+ # HTTP-level 403 challenges remain the transport `required_scopes` gate's job.
773
+ report_exception(e, { request: request })
774
+ add_instrumentation_data(error: :insufficient_scope)
775
+ converted = RequestHandlerError.new(e.message, request, error_type: :invalid_request, original_error: e)
776
+ reported_exception = converted
777
+ raise converted
763
778
  rescue RequestHandlerError => e
764
779
  report_exception(e.original_error || e, { request: request })
765
780
  add_instrumentation_data(error: e.error_type)
@@ -1224,7 +1239,7 @@ module MCP
1224
1239
  apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
1225
1240
  end
1226
1241
 
1227
- def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1242
+ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1228
1243
  tool_name = request[:name]
1229
1244
 
1230
1245
  tool = tools[tool_name]
@@ -1259,12 +1274,13 @@ module MCP
1259
1274
  response = call_tool_with_args(
1260
1275
  tool,
1261
1276
  arguments,
1262
- server_context_with_meta(request),
1277
+ server_context_with_meta(request, auth_info: auth_info),
1263
1278
  progress_token: progress_token,
1264
1279
  session: session,
1265
1280
  related_request_id: related_request_id,
1266
1281
  cancellation: cancellation,
1267
1282
  envelope: envelope,
1283
+ auth_info: auth_info,
1268
1284
  retry_fields: mrtr_retry_fields(request),
1269
1285
  )
1270
1286
  # An SEP-2322 `input_required` result is not a tool result: output schema
@@ -1278,9 +1294,11 @@ module MCP
1278
1294
  result,
1279
1295
  content_provided: response.respond_to?(:content_provided?) && response.content_provided?,
1280
1296
  )
1281
- rescue RequestHandlerError, CancelledError
1297
+ rescue RequestHandlerError, CancelledError, OAuth::InsufficientScopeError
1282
1298
  # CancelledError is intentionally not wrapped so `handle_request` can turn it into
1283
- # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
1299
+ # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec, and
1300
+ # `OAuth::InsufficientScopeError` so `require_scopes!` failures keep their
1301
+ # scope-naming message instead of the generic internal-error shape.
1284
1302
  raise
1285
1303
  rescue => e
1286
1304
  # `e.message` is deliberately not included: it can carry internals (class, method
@@ -1300,7 +1318,7 @@ module MCP
1300
1318
  apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
1301
1319
  end
1302
1320
 
1303
- def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1321
+ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1304
1322
  prompt_name = request[:name]
1305
1323
  prompt = @prompts[prompt_name]
1306
1324
  unless prompt
@@ -1328,6 +1346,7 @@ module MCP
1328
1346
  related_request_id: related_request_id,
1329
1347
  cancellation: cancellation,
1330
1348
  envelope: envelope,
1349
+ auth_info: auth_info,
1331
1350
  )
1332
1351
 
1333
1352
  call_prompt_template_with_args(prompt, prompt_args, server_context)
@@ -1440,7 +1459,7 @@ module MCP
1440
1459
  { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
1441
1460
  end
1442
1461
 
1443
- def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1462
+ def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1444
1463
  validate_completion_params!(params)
1445
1464
 
1446
1465
  result = dispatch_optional_context_handler(
@@ -1450,6 +1469,7 @@ module MCP
1450
1469
  related_request_id: related_request_id,
1451
1470
  cancellation: cancellation,
1452
1471
  envelope: envelope,
1472
+ auth_info: auth_info,
1453
1473
  )
1454
1474
 
1455
1475
  normalize_completion_result(result)
@@ -1458,7 +1478,7 @@ module MCP
1458
1478
  # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
1459
1479
  # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
1460
1480
  # `server_context.raise_if_cancelled!`.
1461
- def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
1481
+ def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1462
1482
  dispatch_optional_context_handler(
1463
1483
  @handlers[Methods::RESOURCES_READ],
1464
1484
  request,
@@ -1466,6 +1486,7 @@ module MCP
1466
1486
  related_request_id: related_request_id,
1467
1487
  cancellation: cancellation,
1468
1488
  envelope: envelope,
1489
+ auth_info: auth_info,
1469
1490
  )
1470
1491
  end
1471
1492
 
@@ -1473,7 +1494,7 @@ module MCP
1473
1494
  # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
1474
1495
  # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
1475
1496
  # 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)
1497
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1477
1498
  return handler.call(params) unless handler_declares_server_context?(handler)
1478
1499
 
1479
1500
  server_context = build_server_context(
@@ -1482,6 +1503,7 @@ module MCP
1482
1503
  related_request_id: related_request_id,
1483
1504
  cancellation: cancellation,
1484
1505
  envelope: envelope,
1506
+ auth_info: auth_info,
1485
1507
  )
1486
1508
  handler.call(params, server_context: server_context)
1487
1509
  end
@@ -1506,13 +1528,13 @@ module MCP
1506
1528
 
1507
1529
  # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
1508
1530
  # (progress, cancellation, nested server-to-client requests).
1509
- def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
1531
+ def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil, auth_info: nil)
1510
1532
  meta_source = request.is_a?(Hash) ? request : {}
1511
1533
  progress_token = meta_source.dig(:_meta, :progressToken)
1512
1534
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
1513
1535
  retry_fields = mrtr_retry_fields(meta_source)
1514
1536
  ServerContext.new(
1515
- server_context_with_meta(meta_source),
1537
+ server_context_with_meta(meta_source, auth_info: auth_info),
1516
1538
  progress: progress,
1517
1539
  notification_target: session,
1518
1540
  related_request_id: related_request_id,
@@ -1520,6 +1542,7 @@ module MCP
1520
1542
  envelope: envelope,
1521
1543
  input_responses: retry_fields[:input_responses],
1522
1544
  request_state: retry_fields[:request_state],
1545
+ auth_info: auth_info,
1523
1546
  )
1524
1547
  end
1525
1548
 
@@ -1579,7 +1602,7 @@ module MCP
1579
1602
  end
1580
1603
  end
1581
1604
 
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)
1605
+ 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
1606
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1584
1607
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1585
1608
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
@@ -1597,6 +1620,7 @@ module MCP
1597
1620
  envelope: envelope,
1598
1621
  input_responses: retry_fields&.fetch(:input_responses, nil),
1599
1622
  request_state: retry_fields&.fetch(:request_state, nil),
1623
+ auth_info: auth_info,
1600
1624
  )
1601
1625
  tool.call(**args, server_context: server_context)
1602
1626
  else
@@ -1614,15 +1638,18 @@ module MCP
1614
1638
  raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
1615
1639
  end
1616
1640
 
1617
- def server_context_with_meta(request)
1641
+ def server_context_with_meta(request, auth_info: nil)
1618
1642
  meta = request[:_meta]
1619
- if meta && server_context.is_a?(Hash)
1620
- context = server_context.dup
1621
- context[:_meta] = meta
1643
+ return server_context if meta.nil? && auth_info.nil?
1644
+
1645
+ if server_context.is_a?(Hash) || server_context.nil?
1646
+ context = (server_context || {}).dup
1647
+ context[:_meta] = meta if meta
1648
+ context[:auth_info] = auth_info if auth_info
1622
1649
  context
1623
- elsif meta && server_context.nil?
1624
- { _meta: meta }
1625
1650
  else
1651
+ # A custom (non-Hash) user context is passed through untouched; handlers on that path read
1652
+ # the token via `server_context.auth_info` instead.
1626
1653
  server_context
1627
1654
  end
1628
1655
  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.
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.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
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.0
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