mcp 1.6.1 → 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"
@@ -181,8 +182,13 @@ module MCP
181
182
  Methods::RESOURCES_READ,
182
183
  ].freeze
183
184
 
184
- attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
185
- attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
185
+ attr_accessor :description, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
186
+ attr_reader :icons, :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
187
+
188
+ # Replaces the icons advertised in `serverInfo`; a Hash is converted through `Icon.from`.
189
+ def icons=(value)
190
+ @icons = Icon.from_list(value)
191
+ end
186
192
 
187
193
  def initialize(
188
194
  description: nil,
@@ -207,7 +213,7 @@ module MCP
207
213
  transport: nil
208
214
  )
209
215
  @description = description
210
- @icons = icons
216
+ @icons = Icon.from_list(icons)
211
217
  @name = name
212
218
  @title = title
213
219
  @version = version
@@ -274,10 +280,13 @@ module MCP
274
280
  # @param session [ServerSession, nil] Per-connection session. Passed by
275
281
  # `ServerSession#handle` for session-scoped notification delivery.
276
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`.
277
286
  # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
278
- def handle(request, session: nil)
287
+ def handle(request, session: nil, auth_info: nil)
279
288
  JsonRpcHandler.handle(request) do |method, request_id|
280
- 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)
281
290
  end
282
291
  end
283
292
 
@@ -287,10 +296,12 @@ module MCP
287
296
  # @param session [ServerSession, nil] Per-connection session. Passed by
288
297
  # `ServerSession#handle_json` for session-scoped notification delivery.
289
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`.
290
301
  # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
291
- def handle_json(request, session: nil)
302
+ def handle_json(request, session: nil, auth_info: nil)
292
303
  JsonRpcHandler.handle_json(request) do |method, request_id|
293
- 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)
294
305
  end
295
306
  end
296
307
 
@@ -595,7 +606,7 @@ module MCP
595
606
  end
596
607
  end
597
608
 
598
- 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)
599
610
  # A well-formed notification carries no JSON-RPC id and receives no response.
600
611
  # If a client erroneously sends a notification-only method with an id, the message
601
612
  # is framed as a request; since notification methods have no request handler,
@@ -686,25 +697,25 @@ module MCP
686
697
  when Methods::INITIALIZE
687
698
  init(params, session: session)
688
699
  when Methods::RESOURCES_READ
689
- 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)
690
701
 
691
702
  # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
692
703
  contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
693
704
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
694
705
  validate_resource_subscription_params!(params)
695
- 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)
696
707
 
697
708
  subscription_result(handler_result)
698
709
  when Methods::TOOLS_CALL
699
- 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)
700
711
  when Methods::PROMPTS_GET
701
- 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)
702
713
  when Methods::COMPLETION_COMPLETE
703
- 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)
704
715
  when Methods::LOGGING_SET_LEVEL
705
716
  configure_logging_level(params, session: session)
706
717
  else
707
- 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)
708
719
  end
709
720
  client = session&.client || @client
710
721
  add_instrumentation_data(client: client) if client
@@ -755,6 +766,15 @@ module MCP
755
766
  rescue CancelledError => e
756
767
  add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
757
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
758
778
  rescue RequestHandlerError => e
759
779
  report_exception(e.original_error || e, { request: request })
760
780
  add_instrumentation_data(error: e.error_type)
@@ -1219,7 +1239,7 @@ module MCP
1219
1239
  apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
1220
1240
  end
1221
1241
 
1222
- 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)
1223
1243
  tool_name = request[:name]
1224
1244
 
1225
1245
  tool = tools[tool_name]
@@ -1254,12 +1274,13 @@ module MCP
1254
1274
  response = call_tool_with_args(
1255
1275
  tool,
1256
1276
  arguments,
1257
- server_context_with_meta(request),
1277
+ server_context_with_meta(request, auth_info: auth_info),
1258
1278
  progress_token: progress_token,
1259
1279
  session: session,
1260
1280
  related_request_id: related_request_id,
1261
1281
  cancellation: cancellation,
1262
1282
  envelope: envelope,
1283
+ auth_info: auth_info,
1263
1284
  retry_fields: mrtr_retry_fields(request),
1264
1285
  )
1265
1286
  # An SEP-2322 `input_required` result is not a tool result: output schema
@@ -1273,9 +1294,11 @@ module MCP
1273
1294
  result,
1274
1295
  content_provided: response.respond_to?(:content_provided?) && response.content_provided?,
1275
1296
  )
1276
- rescue RequestHandlerError, CancelledError
1297
+ rescue RequestHandlerError, CancelledError, OAuth::InsufficientScopeError
1277
1298
  # CancelledError is intentionally not wrapped so `handle_request` can turn it into
1278
- # `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.
1279
1302
  raise
1280
1303
  rescue => e
1281
1304
  # `e.message` is deliberately not included: it can carry internals (class, method
@@ -1295,7 +1318,7 @@ module MCP
1295
1318
  apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
1296
1319
  end
1297
1320
 
1298
- 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)
1299
1322
  prompt_name = request[:name]
1300
1323
  prompt = @prompts[prompt_name]
1301
1324
  unless prompt
@@ -1323,6 +1346,7 @@ module MCP
1323
1346
  related_request_id: related_request_id,
1324
1347
  cancellation: cancellation,
1325
1348
  envelope: envelope,
1349
+ auth_info: auth_info,
1326
1350
  )
1327
1351
 
1328
1352
  call_prompt_template_with_args(prompt, prompt_args, server_context)
@@ -1435,7 +1459,7 @@ module MCP
1435
1459
  { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
1436
1460
  end
1437
1461
 
1438
- 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)
1439
1463
  validate_completion_params!(params)
1440
1464
 
1441
1465
  result = dispatch_optional_context_handler(
@@ -1445,6 +1469,7 @@ module MCP
1445
1469
  related_request_id: related_request_id,
1446
1470
  cancellation: cancellation,
1447
1471
  envelope: envelope,
1472
+ auth_info: auth_info,
1448
1473
  )
1449
1474
 
1450
1475
  normalize_completion_result(result)
@@ -1453,7 +1478,7 @@ module MCP
1453
1478
  # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
1454
1479
  # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
1455
1480
  # `server_context.raise_if_cancelled!`.
1456
- 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)
1457
1482
  dispatch_optional_context_handler(
1458
1483
  @handlers[Methods::RESOURCES_READ],
1459
1484
  request,
@@ -1461,6 +1486,7 @@ module MCP
1461
1486
  related_request_id: related_request_id,
1462
1487
  cancellation: cancellation,
1463
1488
  envelope: envelope,
1489
+ auth_info: auth_info,
1464
1490
  )
1465
1491
  end
1466
1492
 
@@ -1468,7 +1494,7 @@ module MCP
1468
1494
  # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
1469
1495
  # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
1470
1496
  # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
1471
- 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)
1472
1498
  return handler.call(params) unless handler_declares_server_context?(handler)
1473
1499
 
1474
1500
  server_context = build_server_context(
@@ -1477,6 +1503,7 @@ module MCP
1477
1503
  related_request_id: related_request_id,
1478
1504
  cancellation: cancellation,
1479
1505
  envelope: envelope,
1506
+ auth_info: auth_info,
1480
1507
  )
1481
1508
  handler.call(params, server_context: server_context)
1482
1509
  end
@@ -1501,13 +1528,13 @@ module MCP
1501
1528
 
1502
1529
  # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
1503
1530
  # (progress, cancellation, nested server-to-client requests).
1504
- 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)
1505
1532
  meta_source = request.is_a?(Hash) ? request : {}
1506
1533
  progress_token = meta_source.dig(:_meta, :progressToken)
1507
1534
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
1508
1535
  retry_fields = mrtr_retry_fields(meta_source)
1509
1536
  ServerContext.new(
1510
- server_context_with_meta(meta_source),
1537
+ server_context_with_meta(meta_source, auth_info: auth_info),
1511
1538
  progress: progress,
1512
1539
  notification_target: session,
1513
1540
  related_request_id: related_request_id,
@@ -1515,6 +1542,7 @@ module MCP
1515
1542
  envelope: envelope,
1516
1543
  input_responses: retry_fields[:input_responses],
1517
1544
  request_state: retry_fields[:request_state],
1545
+ auth_info: auth_info,
1518
1546
  )
1519
1547
  end
1520
1548
 
@@ -1574,7 +1602,7 @@ module MCP
1574
1602
  end
1575
1603
  end
1576
1604
 
1577
- 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)
1578
1606
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1579
1607
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1580
1608
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
@@ -1592,6 +1620,7 @@ module MCP
1592
1620
  envelope: envelope,
1593
1621
  input_responses: retry_fields&.fetch(:input_responses, nil),
1594
1622
  request_state: retry_fields&.fetch(:request_state, nil),
1623
+ auth_info: auth_info,
1595
1624
  )
1596
1625
  tool.call(**args, server_context: server_context)
1597
1626
  else
@@ -1609,15 +1638,18 @@ module MCP
1609
1638
  raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
1610
1639
  end
1611
1640
 
1612
- def server_context_with_meta(request)
1641
+ def server_context_with_meta(request, auth_info: nil)
1613
1642
  meta = request[:_meta]
1614
- if meta && server_context.is_a?(Hash)
1615
- context = server_context.dup
1616
- 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
1617
1649
  context
1618
- elsif meta && server_context.nil?
1619
- { _meta: meta }
1620
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.
1621
1653
  server_context
1622
1654
  end
1623
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/tool.rb CHANGED
@@ -86,7 +86,7 @@ module MCP
86
86
  if value == NOT_SET
87
87
  @icons_value
88
88
  else
89
- @icons_value = value
89
+ @icons_value = Icon.from_list(value)
90
90
  end
91
91
  end
92
92
 
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.6.1"
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.6.1
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.6.1
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