mcp 1.2.0 → 1.4.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
@@ -219,6 +219,7 @@ module MCP
219
219
  @resources = resources
220
220
  @resource_templates = resource_templates
221
221
  @resource_index = index_resources_by_uri(resources)
222
+ @resources_list_handler = nil
222
223
  @server_context = server_context
223
224
  self.page_size = page_size
224
225
  self.ttl_ms = ttl_ms
@@ -426,6 +427,22 @@ module MCP
426
427
  @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
427
428
  end
428
429
 
430
+ # Sets a custom handler for `resources/list` requests, letting the visible list depend on request context such as
431
+ # the authenticated principal or granted scope. The block returns the resource collection to serve;
432
+ # the framework paginates it and stamps SEP-2549 cache hints exactly as it does for the constructor-provided resources,
433
+ # so the block returns only the array, not the paginated result.
434
+ # A block that declares a `server_context:` keyword receives an `MCP::ServerContext`. When no handler is set,
435
+ # the constructor-provided `resources` array is served unchanged.
436
+ #
437
+ # The block is invoked once per page, so it must return a stable ordering across the pages of one logical query;
438
+ # the cursor is a positional offset into the returned collection.
439
+ #
440
+ # @yield [params, server_context:] The request params, and an `MCP::ServerContext` when declared.
441
+ # @yieldreturn [Array<MCP::Resource>] The resources to paginate.
442
+ def resources_list_handler(&block)
443
+ @resources_list_handler = block
444
+ end
445
+
429
446
  # Sets a custom handler for `resources/read` requests.
430
447
  # The block receives the parsed request params and should return resource
431
448
  # contents. The return value is set as the `contents` field of the response.
@@ -446,19 +463,25 @@ module MCP
446
463
  end
447
464
 
448
465
  # Sets a custom handler for `resources/subscribe` requests.
449
- # The block receives the parsed request params. The return value is
450
- # ignored; the response is always an empty result `{}` per the MCP specification.
466
+ # The block receives the parsed request params. The response is an empty result, except that
467
+ # a `_meta` hash the block returns is passed through - the spec defines no other member for this result,
468
+ # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data
469
+ # under `_meta`.
451
470
  #
452
471
  # @yield [params] The request params containing `:uri`.
472
+ # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
453
473
  def resources_subscribe_handler(&block)
454
474
  @handlers[Methods::RESOURCES_SUBSCRIBE] = block
455
475
  end
456
476
 
457
477
  # Sets a custom handler for `resources/unsubscribe` requests.
458
- # The block receives the parsed request params. The return value is
459
- # ignored; the response is always an empty result `{}` per the MCP specification.
478
+ # The block receives the parsed request params. The response is an empty result, except that
479
+ # a `_meta` hash the block returns is passed through - the spec defines no other member for this result,
480
+ # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data
481
+ # under `_meta`.
460
482
  #
461
483
  # @yield [params] The request params containing `:uri`.
484
+ # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
462
485
  def resources_unsubscribe_handler(&block)
463
486
  @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
464
487
  end
@@ -619,8 +642,24 @@ module MCP
619
642
 
620
643
  # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
621
644
  # so do not track it in the in-flight registry.
622
- cancellation = if related_request_id && method != Methods::INITIALIZE
623
- session&.register_in_flight(related_request_id)
645
+ cancellation = nil
646
+ if related_request_id && method != Methods::INITIALIZE && session
647
+ cancellation = session.register_in_flight(related_request_id)
648
+
649
+ # The spec puts the uniqueness obligation on the sender - "The request ID MUST NOT have been previously used by
650
+ # the requestor within the same session" - and says nothing about what a receiver does with a duplicate.
651
+ # Answering one is the only option that stays correct: the id routes request-scoped messages back to
652
+ # the request that caused them, and the transport's rule is that those messages "SHOULD relate to
653
+ # the originating client request", which a second live request under the same id makes impossible to honor
654
+ # for either of them. Refused the same way a duplicate `initialize` is, and for the same reason:
655
+ # so that a repeated id cannot silently displace state negotiated by the first one.
656
+ if cancellation.nil?
657
+ raise RequestHandlerError.new(
658
+ "Invalid Request: request id #{related_request_id.inspect} is already in flight",
659
+ request,
660
+ error_type: :invalid_request,
661
+ )
662
+ end
624
663
  end
625
664
 
626
665
  ->(params) {
@@ -653,8 +692,9 @@ module MCP
653
692
  contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
654
693
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
655
694
  validate_resource_subscription_params!(params)
656
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
657
- {}
695
+ handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
696
+
697
+ subscription_result(handler_result)
658
698
  when Methods::TOOLS_CALL
659
699
  call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
660
700
  when Methods::PROMPTS_GET
@@ -727,7 +767,10 @@ module MCP
727
767
  reported_exception = wrapped
728
768
  raise wrapped
729
769
  ensure
730
- session&.unregister_in_flight(related_request_id) if related_request_id
770
+ # `cancellation` is non-nil exactly when this request claimed the id above, so this also keeps `initialize`
771
+ # (which never registers) from evicting an in-flight registration under a reused id when the duplicate-`initialize`
772
+ # refusal raises out of the handler.
773
+ session&.unregister_in_flight(related_request_id, cancellation: cancellation) if related_request_id && cancellation
731
774
  end
732
775
  }
733
776
  end
@@ -1080,6 +1123,18 @@ module MCP
1080
1123
  end
1081
1124
  end
1082
1125
 
1126
+ # The `resources/subscribe` and `resources/unsubscribe` result is an empty object except for the optional `_meta`
1127
+ # every result may carry: the TypeScript SDK validates it against `EmptyResultSchema.strict()`,
1128
+ # which rejects any other member, so only `_meta` is passed through from the handler. A handler that returns
1129
+ # anything else keeps the empty `{}` result it had before, so returning a subscription identifier or
1130
+ # other advisory data means nesting it under `_meta`.
1131
+ def subscription_result(handler_result)
1132
+ return {} unless handler_result.is_a?(Hash)
1133
+
1134
+ meta = handler_result[:_meta] || handler_result["_meta"]
1135
+ meta.is_a?(Hash) ? { _meta: meta } : {}
1136
+ end
1137
+
1083
1138
  def validate_initialize_params!(params)
1084
1139
  unless params.is_a?(Hash)
1085
1140
  raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
@@ -1273,12 +1328,28 @@ module MCP
1273
1328
  call_prompt_template_with_args(prompt, prompt_args, server_context)
1274
1329
  end
1275
1330
 
1276
- def list_resources(request)
1277
- page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
1331
+ def list_resources(request, server_context: nil)
1332
+ resources = if @resources_list_handler
1333
+ invoke_resources_list_handler(request, server_context)
1334
+ else
1335
+ @resources
1336
+ end
1337
+
1338
+ page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
1278
1339
 
1279
1340
  apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
1280
1341
  end
1281
1342
 
1343
+ # Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in
1344
+ # by declaring the keyword (the same rule `dispatch_optional_context_handler` applies).
1345
+ def invoke_resources_list_handler(request, server_context)
1346
+ if handler_declares_server_context?(@resources_list_handler)
1347
+ @resources_list_handler.call(request, server_context: server_context)
1348
+ else
1349
+ @resources_list_handler.call(request)
1350
+ end
1351
+ end
1352
+
1282
1353
  # Default `resources/read` handler: routes to class-based resources and resource templates.
1283
1354
  # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
1284
1355
  # unknown URIs keep the historical no-op `[]` response instead of raising.
@@ -1507,7 +1578,7 @@ module MCP
1507
1578
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1508
1579
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1509
1580
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
1510
- # See docs/building-servers.md ("Tool argument keys").
1581
+ # See docs/server/tools.md ("Tool argument keys").
1511
1582
  args = arguments&.transform_keys(&:to_sym) || {}
1512
1583
 
1513
1584
  if accepts_server_context?(tool.method(:call))
@@ -58,19 +58,41 @@ module MCP
58
58
  @era = era
59
59
  end
60
60
 
61
- # Registers a `Cancellation` token for an in-flight request.
61
+ # Registers a `Cancellation` token for an in-flight request, or returns `nil` when `request_id` is already in flight.
62
+ # The request id is the only key that routes request-scoped notifications, server-to-client requests,
63
+ # and `notifications/cancelled` back to the request that caused them, so a second live request under the same id
64
+ # has no destination of its own. Rather than let the newcomer displace the registration, report the collision
65
+ # and leave the first request intact; the caller turns that into an Invalid Request.
62
66
  def register_in_flight(request_id)
63
67
  return if request_id.nil?
64
68
 
65
69
  cancellation = Cancellation.new(request_id: request_id)
66
- @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation }
67
- cancellation
70
+ registered = @in_flight_mutex.synchronize do
71
+ next false if @in_flight.key?(request_id)
72
+
73
+ @in_flight[request_id] = cancellation
74
+ true
75
+ end
76
+
77
+ registered ? cancellation : nil
68
78
  end
69
79
 
70
- def unregister_in_flight(request_id)
80
+ # Removes an in-flight registration. Passing the `Cancellation` that `register_in_flight` returned removes
81
+ # the entry only while it is still that one, so a request can never evict a registration it does not own.
82
+ def unregister_in_flight(request_id, cancellation: nil)
71
83
  return if request_id.nil?
72
84
 
73
- @in_flight_mutex.synchronize { @in_flight.delete(request_id) }
85
+ @in_flight_mutex.synchronize do
86
+ next if cancellation && !@in_flight[request_id].equal?(cancellation)
87
+
88
+ @in_flight.delete(request_id)
89
+ end
90
+ end
91
+
92
+ # Whether `request_id` is currently in flight, so a transport can refuse a colliding request
93
+ # before registering any state of its own for it.
94
+ def in_flight?(request_id)
95
+ !lookup_in_flight(request_id).nil?
74
96
  end
75
97
 
76
98
  def lookup_in_flight(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.2.0"
4
+ VERSION = "1.4.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.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -46,6 +46,7 @@ files:
46
46
  - lib/mcp/client/mcp_param_headers.rb
47
47
  - lib/mcp/client/modern_envelope.rb
48
48
  - lib/mcp/client/oauth.rb
49
+ - lib/mcp/client/oauth/bounded_body.rb
49
50
  - lib/mcp/client/oauth/client_credentials_provider.rb
50
51
  - lib/mcp/client/oauth/cross_app_access_provider.rb
51
52
  - lib/mcp/client/oauth/discovery.rb
@@ -106,7 +107,7 @@ licenses:
106
107
  - Apache-2.0
107
108
  metadata:
108
109
  allowed_push_host: https://rubygems.org
109
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.2.0
110
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.4.0
110
111
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
111
112
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
112
113
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues