mcp 0.25.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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  require_relative "../json_rpc_handler"
4
6
  require_relative "cancellation"
5
7
  require_relative "cancelled_error"
@@ -7,9 +9,13 @@ require_relative "instrumentation"
7
9
  require_relative "methods"
8
10
  require_relative "logging_message_notification"
9
11
  require_relative "progress"
12
+ require_relative "protocol_deprecations"
10
13
  require_relative "server_context"
11
14
  require_relative "server/capabilities"
15
+ require_relative "server/input_required_result"
12
16
  require_relative "server/pagination"
17
+ require_relative "server/pending_response"
18
+ require_relative "server/request_state_security"
13
19
  require_relative "server/transports"
14
20
 
15
21
  module MCP
@@ -59,6 +65,43 @@ module MCP
59
65
  end
60
66
  end
61
67
 
68
+ # Raised when a request carries a protocol version the server does not support under the stateless lifecycle of
69
+ # MCP 2026-07-28 (SEP-2575). Maps to JSON-RPC error `-32022` with `data: { supported: [...], requested: "..." }`
70
+ # so the client can select a mutually supported version and retry.
71
+ #
72
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
73
+ class UnsupportedProtocolVersionError < RequestHandlerError
74
+ # No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash
75
+ # into keywords and fail with "unknown keywords".
76
+ def initialize(requested, request = nil)
77
+ super(
78
+ "Unsupported protocol version",
79
+ request,
80
+ error_type: :unsupported_protocol_version,
81
+ error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
82
+ error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" },
83
+ )
84
+ end
85
+ end
86
+
87
+ # Raised when processing a request requires a client capability the request did not declare in `_meta`
88
+ # (`io.modelcontextprotocol/clientCapabilities`). Per SEP-2575, servers MUST NOT rely on capabilities
89
+ # the client has not declared. Maps to JSON-RPC error `-32021` with `data: { requiredCapabilities: {...} }`
90
+ # listing the missing capabilities.
91
+ #
92
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
93
+ class MissingRequiredClientCapabilityError < RequestHandlerError
94
+ def initialize(required_capabilities, request = nil)
95
+ super(
96
+ "Missing required client capability",
97
+ request,
98
+ error_type: :missing_required_client_capability,
99
+ error_code: ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
100
+ error_data: { requiredCapabilities: required_capabilities },
101
+ )
102
+ end
103
+ end
104
+
62
105
  # Raised when a requested resource URI does not exist. Per SEP-2164,
63
106
  # resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602)
64
107
  # with the requested URI in the error `data` member. Raise this from
@@ -85,6 +128,27 @@ module MCP
85
128
  end
86
129
  end
87
130
 
131
+ # Raised when a server-to-client request (sampling, elicitation, `roots/list`, `ping`) goes unanswered past its timeout.
132
+ # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources,
133
+ # and to cancel the request on expiry; the transport sends `notifications/cancelled` before raising this.
134
+ # These requests exist only on connections speaking 2025-11-25 or earlier, since the modern lifecycle forbids them.
135
+ #
136
+ # Left uncaught in a handler, this answers the client's originating request with `-32001` rather than a generic
137
+ # internal error, so the peer that failed to answer can tell a timeout apart from a server fault. The code is not
138
+ # spec-allocated: it sits in the implementation-defined server range and is the value the Python SDK reports for
139
+ # this condition, so a client that already recognizes it there reads the same meaning here.
140
+ #
141
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
142
+ class RequestTimeoutError < RequestHandlerError
143
+ attr_reader :request_id, :timeout
144
+
145
+ def initialize(message, request_id:, timeout:)
146
+ super(message, nil, error_type: :request_timeout, error_code: -32001)
147
+ @request_id = request_id
148
+ @timeout = timeout
149
+ end
150
+ end
151
+
88
152
  class MethodAlreadyDefinedError < StandardError
89
153
  attr_reader :method_name
90
154
 
@@ -105,8 +169,20 @@ module MCP
105
169
  # Allowed values for the SEP-2549 `cacheScope` cache hint.
106
170
  CACHE_SCOPES = ["public", "private"].freeze
107
171
 
172
+ # Methods whose results are cacheable per SEP-2549.
173
+ # On the modern wire (2026-07-28) the `ttlMs`/`cacheScope` hints are REQUIRED on these results,
174
+ # so unset hints get the spec defaults there; on stable protocol versions emission stays opt-in
175
+ # via `apply_cache_metadata`.
176
+ CACHEABLE_RESULT_METHODS = [
177
+ Methods::TOOLS_LIST,
178
+ Methods::PROMPTS_LIST,
179
+ Methods::RESOURCES_LIST,
180
+ Methods::RESOURCES_TEMPLATES_LIST,
181
+ Methods::RESOURCES_READ,
182
+ ].freeze
183
+
108
184
  attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
109
- attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope
185
+ attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
110
186
 
111
187
  def initialize(
112
188
  description: nil,
@@ -126,6 +202,8 @@ module MCP
126
202
  page_size: nil,
127
203
  ttl_ms: nil,
128
204
  cache_scope: nil,
205
+ request_state_security: nil,
206
+ input_required_legacy_shim: true,
129
207
  transport: nil
130
208
  )
131
209
  @description = description
@@ -141,12 +219,21 @@ module MCP
141
219
  @resources = resources
142
220
  @resource_templates = resource_templates
143
221
  @resource_index = index_resources_by_uri(resources)
222
+ @resources_list_handler = nil
144
223
  @server_context = server_context
145
224
  self.page_size = page_size
146
225
  self.ttl_ms = ttl_ms
147
226
  self.cache_scope = cache_scope
227
+ @request_state_security = request_state_security
228
+
229
+ # Dual-era authoring (SEP-2322): on the legacy wire, an `input_required` result is fulfilled
230
+ # through real server-to-client requests and the handler re-runs, so handlers written
231
+ # in the 2026 style serve both eras. `false` restores the strict rejection of `input_required`
232
+ # on legacy requests. Matches the TypeScript SDK's default-on legacy shim.
233
+ @input_required_legacy_shim = input_required_legacy_shim
148
234
  @configuration = MCP.configuration.merge(configuration)
149
235
  @client = nil
236
+ @client_protocol_version = nil
150
237
 
151
238
  validate!
152
239
 
@@ -340,6 +427,22 @@ module MCP
340
427
  @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
341
428
  end
342
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
+
343
446
  # Sets a custom handler for `resources/read` requests.
344
447
  # The block receives the parsed request params and should return resource
345
448
  # contents. The return value is set as the `contents` field of the response.
@@ -360,19 +463,25 @@ module MCP
360
463
  end
361
464
 
362
465
  # Sets a custom handler for `resources/subscribe` requests.
363
- # The block receives the parsed request params. The return value is
364
- # 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`.
365
470
  #
366
471
  # @yield [params] The request params containing `:uri`.
472
+ # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
367
473
  def resources_subscribe_handler(&block)
368
474
  @handlers[Methods::RESOURCES_SUBSCRIBE] = block
369
475
  end
370
476
 
371
477
  # Sets a custom handler for `resources/unsubscribe` requests.
372
- # The block receives the parsed request params. The return value is
373
- # 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`.
374
482
  #
375
483
  # @yield [params] The request params containing `:uri`.
484
+ # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
376
485
  def resources_unsubscribe_handler(&block)
377
486
  @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
378
487
  end
@@ -510,12 +619,47 @@ module MCP
510
619
  return
511
620
  end
512
621
 
513
- Methods.ensure_capability!(method, capabilities)
622
+ # SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found
623
+ # there regardless of the server's declared capabilities: in this era the method does not exist
624
+ # at all, which is why the check precedes `ensure_capability!`.
625
+ if Methods::MODERN_REMOVED_METHODS.include?(method) && modern_request?(request, session)
626
+ raise RequestHandlerError.new(
627
+ "Method not found: #{method} is not part of the modern lifecycle (SEP-2575)",
628
+ request,
629
+ error_type: :method_not_found,
630
+ error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
631
+ )
632
+ end
633
+
634
+ begin
635
+ Methods.ensure_capability!(method, capabilities)
636
+ rescue Methods::MissingRequiredCapabilityError => e
637
+ # Re-raise through RequestHandlerError, the one channel whose message is
638
+ # deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError`
639
+ # no longer echoes exception messages into the error `data` member (CWE-209).
640
+ raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e)
641
+ end
514
642
 
515
643
  # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
516
644
  # so do not track it in the in-flight registry.
517
- cancellation = if related_request_id && method != Methods::INITIALIZE
518
- 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
519
663
  end
520
664
 
521
665
  ->(params) {
@@ -525,25 +669,42 @@ module MCP
525
669
  server_context: { request: request },
526
670
  exception_already_reported: ->(e) { reported_exception.equal?(e) },
527
671
  ) do
672
+ envelope = lift_request_envelope(params, method: method, session: session)
673
+
674
+ # The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle:
675
+ # it authorizes `notifications/message` for this request only (SEP-2575). Without it,
676
+ # `ServerSession#notify_log_message` stays silent on modern-era sessions. An unrecognized level
677
+ # reads as absent, so delivery stays off (the safe direction), matching the Python SDK.
678
+ if envelope&.log_level && session.respond_to?(:configure_logging)
679
+ request_logging = LoggingMessageNotification.new(level: envelope.log_level)
680
+ session.configure_logging(request_logging) if request_logging.valid_level?
681
+ end
682
+
683
+ params = unseal_request_state(params, method: method) if @request_state_security
684
+
528
685
  result = case method
529
686
  when Methods::INITIALIZE
530
687
  init(params, session: session)
531
688
  when Methods::RESOURCES_READ
532
- build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation))
689
+ contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
690
+
691
+ # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
692
+ contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
533
693
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
534
694
  validate_resource_subscription_params!(params)
535
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
536
- {}
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)
537
698
  when Methods::TOOLS_CALL
538
- call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
699
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
539
700
  when Methods::PROMPTS_GET
540
- get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
701
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
541
702
  when Methods::COMPLETION_COMPLETE
542
- complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
703
+ complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
543
704
  when Methods::LOGGING_SET_LEVEL
544
705
  configure_logging_level(params, session: session)
545
706
  else
546
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
707
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
547
708
  end
548
709
  client = session&.client || @client
549
710
  add_instrumentation_data(client: client) if client
@@ -553,6 +714,43 @@ module MCP
553
714
  next JsonRpcHandler::NO_RESPONSE
554
715
  end
555
716
 
717
+ # Runs after the cancellation check so a cancelled request stays suppressed
718
+ # instead of turning into a gate error response.
719
+ if result.is_a?(InputRequiredResult)
720
+ result = if envelope.nil? && @input_required_legacy_shim && session
721
+ run_legacy_input_required_shim(
722
+ result,
723
+ method: method,
724
+ params: params,
725
+ session: session,
726
+ related_request_id: related_request_id,
727
+ cancellation: cancellation,
728
+ )
729
+ else
730
+ serialize_input_required_result(result, envelope: envelope, request: params, method: method)
731
+ end
732
+ end
733
+
734
+ # SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns;
735
+ # a missing value is only tolerated FROM earlier protocol versions. Results that already carry
736
+ # a discriminator keep it; everything else on the modern wire is the standard shape,
737
+ # `"complete"`. Legacy results stay unstamped: pre-2026 clients do not know the field.
738
+ if envelope && result.is_a?(Hash) && !result.key?(:resultType)
739
+ result = result.merge(resultType: ResultType::COMPLETE)
740
+ end
741
+
742
+ # SEP-2549 makes the `ttlMs`/`cacheScope` hints REQUIRED on cacheable results at 2026-07-28,
743
+ # so unconfigured servers get `ttlMs: 0` (do not cache) and `cacheScope: "private"`:
744
+ # the spec names no default scope, and `"private"` is the side that cannot leak
745
+ # a user-dependent result through a shared cache, matching the TypeScript SDK's default
746
+ # and `server/discover`. Values already in the result win.
747
+ # Only complete results are cacheable: an SEP-2322 `input_required` round trip must not be stamped
748
+ # (the stamp above guarantees `resultType` is present on every modern Hash result by this point).
749
+ if envelope && result.is_a?(Hash) && CACHEABLE_RESULT_METHODS.include?(method) &&
750
+ result[:resultType] == ResultType::COMPLETE && !(result.key?(:ttlMs) && result.key?(:cacheScope))
751
+ result = { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
752
+ end
753
+
556
754
  result
557
755
  rescue CancelledError => e
558
756
  add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
@@ -569,11 +767,267 @@ module MCP
569
767
  reported_exception = wrapped
570
768
  raise wrapped
571
769
  ensure
572
- 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
573
774
  end
574
775
  }
575
776
  end
576
777
 
778
+ # Whether this request belongs to the modern lifecycle, used to refuse the RPCs SEP-2575 removed from it.
779
+ # A locked `ServerSession#era` is authoritative; until it locks, the request's own `_meta` envelope is
780
+ # the signal. Both are needed because `StdioTransport` locks the era only after a response succeeds,
781
+ # which would otherwise let the first request of a connection reach a method the modern lifecycle does not have.
782
+ # The Python SDK gates the same way, on the envelope of each request rather than on connection state that
783
+ # is only settled afterwards.
784
+ #
785
+ # A legacy-locked session is deliberately excluded: `lift_request_envelope` answers a modern envelope there
786
+ # with the lifecycle violation, which names the cause better than Method not found.
787
+ def modern_request?(request, session)
788
+ era = session.respond_to?(:era) ? session.era : nil
789
+ return true if era == :modern
790
+ return false unless era.nil?
791
+
792
+ RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil)
793
+ end
794
+
795
+ # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
796
+ # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
797
+ # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
798
+ # pre-version discovery, so both are exempt. Era-locked sessions additionally enforce the dual-era rules:
799
+ # on a modern session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake)
800
+ # and the triple becomes required for every other request; on a legacy session, a modern envelope is rejected as
801
+ # an invalid request because a connection can never change eras.
802
+ def lift_request_envelope(params, method:, session:)
803
+ return if Methods.notification?(method)
804
+
805
+ era = session.respond_to?(:era) ? session.era : nil
806
+
807
+ # Outside the modern era, `server/discover` stays envelope-exempt so legacy connections can probe capabilities
808
+ # before any negotiation. Under the modern era every request carries the envelope, discovery included:
809
+ # the conformance suite's 2026-07-28 requirements reject an envelope-less `server/discover` with `-32602` (SEP-2575).
810
+ return if method == Methods::SERVER_DISCOVER && era != :modern
811
+
812
+ if RequestEnvelope.modern?(params)
813
+ if era == :legacy
814
+ raise RequestHandlerError.new(
815
+ "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
816
+ params,
817
+ error_type: :invalid_request,
818
+ )
819
+ end
820
+
821
+ RequestEnvelope.parse!(params, request: params)
822
+ elsif era == :modern
823
+ # A claim-less request on a modern session is a malformed envelope, not a malformed request:
824
+ # the spec maps missing required envelope fields to Invalid params (`-32602`),
825
+ # and the reference SDKs answer it naming the missing keys.
826
+ raise RequestHandlerError.new(
827
+ "Invalid params: missing or invalid `#{RequestEnvelope::REQUIRED_META_KEYS.join("`, `")}` in `_meta`",
828
+ params,
829
+ error_type: :invalid_params,
830
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
831
+ )
832
+ end
833
+ end
834
+
835
+ # Central gate and serializer for SEP-2322 `input_required` results, run once in the dispatch lambda for
836
+ # whichever handler produced one. The result type exists only in the 2026-07-28 stateless lifecycle,
837
+ # so a legacy request (no envelope) must not receive it: pre-2026 clients treat an unknown `resultType` as
838
+ # a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on
839
+ # (or embed requests for) capabilities the client did not declare, and reports every missing capability at
840
+ # once so the client sees the full set.
841
+ def serialize_input_required_result(result, envelope:, request:, method:)
842
+ if envelope.nil?
843
+ raise RequestHandlerError.new(
844
+ "input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)",
845
+ request,
846
+ error_type: :internal_error,
847
+ )
848
+ end
849
+
850
+ missing = result.missing_client_capabilities(envelope.client_capabilities)
851
+ raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty?
852
+
853
+ add_instrumentation_data(input_required: true)
854
+ serialized = result.to_h
855
+
856
+ if @request_state_security && serialized[:requestState]
857
+ serialized = serialized.merge(requestState: @request_state_security.seal(
858
+ serialized[:requestState],
859
+ method: method,
860
+ target: mrtr_target(request),
861
+ arguments_digest: mrtr_arguments_digest(request),
862
+ ))
863
+ end
864
+
865
+ serialized
866
+ end
867
+
868
+ # Methods whose results may be `input_required` and whose retried requests carry
869
+ # `inputResponses`/`requestState` (SEP-2322).
870
+ MRTR_METHODS = [Methods::TOOLS_CALL, Methods::PROMPTS_GET, Methods::RESOURCES_READ].freeze
871
+
872
+ # Fulfilment rounds the legacy shim runs before giving up, matching the TypeScript SDK's legacy shim default (`maxRounds: 8`).
873
+ LEGACY_INPUT_REQUIRED_MAX_ROUNDS = 8
874
+
875
+ # Dual-era authoring shim (SEP-2322): a handler on the legacy wire returned an `input_required` result,
876
+ # which pre-2026 clients cannot understand, so the server fulfills it in place of the client's driver.
877
+ # Every entry of `inputRequests` is sent as the equivalent real server-to-client request (associated with
878
+ # the originating request per SEP-2260), the answers are collected under the same keys, and the handler
879
+ # re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay
880
+ # contract the modern client driver follows. The `requestState` round-trips in-process as the raw value
881
+ # the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply.
882
+ def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:)
883
+ rounds = 0
884
+
885
+ loop do
886
+ missing = result.missing_client_capabilities(session.client_capabilities)
887
+ unless missing.empty?
888
+ # The explicit `error_code` keeps the descriptive message in the JSON-RPC error response
889
+ # (the `ResourceNotFoundError` pattern). `-32021` is a 2026-07-28 code, so the legacy wire
890
+ # gets a plain internal error.
891
+ raise RequestHandlerError.new(
892
+ "input_required requires client capabilities the client did not declare: #{missing.to_json}",
893
+ params,
894
+ error_type: :internal_error,
895
+ error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
896
+ )
897
+ end
898
+
899
+ responses = (result.input_requests || {}).each_with_object({}) do |(key, entry), collected|
900
+ collected[key] = session.fulfill_input_request(
901
+ entry[:method],
902
+ legacy_leg_params(entry),
903
+ related_request_id: related_request_id,
904
+ )
905
+ end
906
+
907
+ retry_params = params.reject { |key, _| [:inputResponses, :requestState].include?(key.to_sym) }
908
+ retry_params[:inputResponses] = responses unless responses.empty?
909
+ retry_params[:requestState] = result.request_state if result.request_state
910
+
911
+ result = redispatch_mrtr_method(
912
+ method,
913
+ retry_params,
914
+ session: session,
915
+ related_request_id: related_request_id,
916
+ cancellation: cancellation,
917
+ )
918
+ return result unless result.is_a?(InputRequiredResult)
919
+
920
+ rounds += 1
921
+ next if rounds < LEGACY_INPUT_REQUIRED_MAX_ROUNDS
922
+
923
+ raise RequestHandlerError.new(
924
+ "Handler still returned `input_required` after #{LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds (SEP-2322)",
925
+ params,
926
+ error_type: :internal_error,
927
+ error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
928
+ )
929
+ end
930
+ end
931
+
932
+ # The params an embedded entry sends on its legacy leg. Entries are forwarded verbatim with
933
+ # one exception: the 2025-11-25 wire requires `elicitationId` on URL-mode elicitation requests,
934
+ # a field the 2026-07-28 in-band shape dropped (correlation rides `requestState` there),
935
+ # so a missing one is synthesized for the leg, matching the TypeScript SDK's shim.
936
+ def legacy_leg_params(entry)
937
+ params = entry[:params]
938
+ return params unless entry[:method] == Methods::ELICITATION_CREATE
939
+ return params if params.nil? || (params[:mode] || params["mode"]) != "url"
940
+ return params if params.key?(:elicitationId) || params.key?("elicitationId")
941
+
942
+ params.merge(elicitationId: SecureRandom.uuid)
943
+ end
944
+
945
+ # Re-runs the handler of one of the three MRTR-capable methods for
946
+ # the legacy shim. Legacy wire, so no envelope is threaded.
947
+ def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:)
948
+ case method
949
+ when Methods::TOOLS_CALL
950
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
951
+ when Methods::PROMPTS_GET
952
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
953
+ when Methods::RESOURCES_READ
954
+ contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
955
+ contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
956
+ else
957
+ raise RequestHandlerError.new(
958
+ "input_required results are only supported for #{MRTR_METHODS.join(", ")}",
959
+ params,
960
+ error_type: :internal_error,
961
+ )
962
+ end
963
+ end
964
+
965
+ # Replaces a sealed client-echoed `requestState` with its verified plaintext before dispatch,
966
+ # so handlers always read the state they wrote. A tampered, expired, or cross-request token is
967
+ # rejected as invalid params, matching the Python SDK's "Invalid or expired requestState" behavior.
968
+ def unseal_request_state(params, method:)
969
+ return params unless MRTR_METHODS.include?(method)
970
+ return params unless params.is_a?(Hash)
971
+
972
+ sealed = params[:requestState] || params["requestState"]
973
+ return params unless sealed
974
+
975
+ plaintext = @request_state_security.unseal(
976
+ sealed,
977
+ method: method,
978
+ target: mrtr_target(params),
979
+ arguments_digest: mrtr_arguments_digest(params),
980
+ )
981
+ key = params.key?("requestState") ? "requestState" : :requestState
982
+ params.merge(key => plaintext)
983
+ rescue RequestStateSecurity::InvalidStateError => e
984
+ raise RequestHandlerError.new(
985
+ "Invalid or expired requestState",
986
+ params,
987
+ error_type: :invalid_params,
988
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
989
+ original_error: e,
990
+ )
991
+ end
992
+
993
+ def mrtr_target(params)
994
+ return "" unless params.is_a?(Hash)
995
+
996
+ params[:name] || params["name"] || params[:uri] || params["uri"] || ""
997
+ end
998
+
999
+ # Digest of the originating arguments, binding a sealed state to retries of
1000
+ # the same call with the same inputs. Keys are stringified and sorted recursively
1001
+ # so symbol/string parses of identical JSON digest identically.
1002
+ def mrtr_arguments_digest(params)
1003
+ arguments = params.is_a?(Hash) ? params[:arguments] || params["arguments"] : nil
1004
+ OpenSSL::Digest::SHA256.hexdigest(canonical_json(arguments || {}))
1005
+ end
1006
+
1007
+ def canonical_json(value)
1008
+ case value
1009
+ when Hash
1010
+ pairs = value.map { |key, nested| [key.to_s, nested] }.sort_by(&:first)
1011
+ "{#{pairs.map { |key, nested| "#{key.to_json}:#{canonical_json(nested)}" }.join(",")}}"
1012
+ when Array
1013
+ "[#{value.map { |element| canonical_json(element) }.join(",")}]"
1014
+ else
1015
+ value.to_json
1016
+ end
1017
+ end
1018
+
1019
+ # Extracts the SEP-2322 retry fields a client sends when re-issuing a request:
1020
+ # `inputResponses` (answers keyed like the earlier `inputRequests`) and the echoed opaque `requestState`.
1021
+ # They are params-top-level siblings of `name`/`arguments`/ `uri`, not `_meta` entries.
1022
+ def mrtr_retry_fields(params)
1023
+ return { input_responses: nil, request_state: nil } unless params.is_a?(Hash)
1024
+
1025
+ {
1026
+ input_responses: params[:inputResponses] || params["inputResponses"],
1027
+ request_state: params[:requestState] || params["requestState"],
1028
+ }
1029
+ end
1030
+
577
1031
  def handle_cancelled_notification(params, session: nil)
578
1032
  return unless session
579
1033
  return unless params.is_a?(Hash)
@@ -623,10 +1077,19 @@ module MCP
623
1077
  end
624
1078
  protocol_version = params[:protocolVersion]
625
1079
 
626
- negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
1080
+ # Per the SEP-2575 era model, `initialize` negotiates legacy protocol versions only: a modern version is
1081
+ # defined by carrying its own version on every request in `_meta`, with no handshake,
1082
+ # so asking `initialize` for one (or for a version this server does not know) is answered with
1083
+ # a counter-offer instead of an echo, matching the TypeScript and Python SDKs. Modern versions
1084
+ # stay reachable through `server/discover` and the per-request envelope. The counter-offer is
1085
+ # a configured `protocol_version` pin when one is set (`Configuration` only accepts handshake versions there),
1086
+ # and the latest handshake version otherwise.
1087
+ negotiated_version = if Configuration.handshake_protocol_version?(protocol_version)
627
1088
  protocol_version
628
- else
1089
+ elsif configuration.protocol_version?
629
1090
  configuration.protocol_version
1091
+ else
1092
+ Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
630
1093
  end
631
1094
 
632
1095
  info = server_info.reject do |property|
@@ -640,7 +1103,11 @@ module MCP
640
1103
  response_instructions = nil
641
1104
  end
642
1105
 
643
- session&.mark_initialized!
1106
+ if session
1107
+ session.mark_initialized!(protocol_version: negotiated_version)
1108
+ else
1109
+ @client_protocol_version = negotiated_version
1110
+ end
644
1111
 
645
1112
  {
646
1113
  protocolVersion: negotiated_version,
@@ -656,6 +1123,18 @@ module MCP
656
1123
  end
657
1124
  end
658
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
+
659
1138
  def validate_initialize_params!(params)
660
1139
  unless params.is_a?(Hash)
661
1140
  raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
@@ -675,20 +1154,47 @@ module MCP
675
1154
  end
676
1155
  end
677
1156
 
678
- # Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery.
1157
+ # Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery.
679
1158
  # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
680
1159
  # the session initialized, and responds regardless of capability declarations or initialization state,
681
- # so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered
682
- # because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints
683
- # are not included here yet.
1160
+ # so clients can probe a server before (or instead of) `initialize`.
1161
+ #
1162
+ # `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
1163
+ # legacy versions are negotiated via `initialize`, not selected from discovery. The `ttlMs`/`cacheScope`
1164
+ # cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list/read results),
1165
+ # so the spec defaults (`0` = immediately stale, `"private"` = per-authorization-context caching only)
1166
+ # fill in when the server was not configured with `ttl_ms`/`cache_scope`. The server identity rides
1167
+ # in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp per the finalized
1168
+ # spec (PR #3002), unfiltered because discovery happens before version negotiation.
684
1169
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
685
1170
  def discover(_request)
686
1171
  {
687
- supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS,
688
- capabilities: capabilities,
689
- serverInfo: server_info,
1172
+ supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
1173
+ capabilities: discover_capabilities,
690
1174
  instructions: instructions,
691
- }.compact
1175
+ _meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info },
1176
+ }.compact.merge(
1177
+ ttlMs: ttl_ms || 0,
1178
+ cacheScope: cache_scope || "private",
1179
+ # `server/discover` is exempt from the `_meta` envelope, so the central modern-result stamping does not see it;
1180
+ # `DiscoverResult` is still a 2026-07-28 result and carries the REQUIRED `resultType` directly.
1181
+ resultType: ResultType::COMPLETE,
1182
+ )
1183
+ end
1184
+
1185
+ # Capabilities as advertised by `server/discover`. In the modern lifecycle, `listChanged` and `subscribe` flags
1186
+ # promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC
1187
+ # (e.g. stdio), matching the Python SDK's era-aware capability derivation.
1188
+ def discover_capabilities
1189
+ return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
1190
+
1191
+ capabilities.each_with_object({}) do |(name, value), stripped|
1192
+ stripped[name] = if value.is_a?(Hash)
1193
+ value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) }
1194
+ else
1195
+ value
1196
+ end
1197
+ end
692
1198
  end
693
1199
 
694
1200
  def configure_logging_level(request, session: nil)
@@ -713,7 +1219,7 @@ module MCP
713
1219
  apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
714
1220
  end
715
1221
 
716
- def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
1222
+ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
717
1223
  tool_name = request[:name]
718
1224
 
719
1225
  tool = tools[tool_name]
@@ -745,18 +1251,38 @@ module MCP
745
1251
 
746
1252
  progress_token = request.dig(:_meta, :progressToken)
747
1253
 
748
- result = call_tool_with_args(
749
- tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
1254
+ response = call_tool_with_args(
1255
+ tool,
1256
+ arguments,
1257
+ server_context_with_meta(request),
1258
+ progress_token: progress_token,
1259
+ session: session,
1260
+ related_request_id: related_request_id,
1261
+ cancellation: cancellation,
1262
+ envelope: envelope,
1263
+ retry_fields: mrtr_retry_fields(request),
750
1264
  )
1265
+ # An SEP-2322 `input_required` result is not a tool result: output schema
1266
+ # validation would run against a `nil` `structuredContent` and the structured
1267
+ # content fallback does not apply. The dispatch lambda serializes it.
1268
+ return response if response.is_a?(InputRequiredResult)
1269
+
1270
+ result = response.to_h
751
1271
  validate_tool_call_result!(tool, result)
752
- serialize_structured_content_fallback(result)
1272
+ serialize_structured_content_fallback(
1273
+ result,
1274
+ content_provided: response.respond_to?(:content_provided?) && response.content_provided?,
1275
+ )
753
1276
  rescue RequestHandlerError, CancelledError
754
1277
  # CancelledError is intentionally not wrapped so `handle_request` can turn it into
755
1278
  # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
756
1279
  raise
757
1280
  rescue => e
1281
+ # `e.message` is deliberately not included: it can carry internals (class, method
1282
+ # and host names) that must not reach untrusted clients (CWE-209). The original
1283
+ # exception still reaches `configuration.exception_reporter` via `original_error`.
758
1284
  raise RequestHandlerError.new(
759
- "Internal error calling tool #{tool_name}: #{e.message}",
1285
+ "Internal error calling tool #{tool_name}",
760
1286
  request,
761
1287
  error_type: :internal_error,
762
1288
  original_error: e,
@@ -769,12 +1295,21 @@ module MCP
769
1295
  apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
770
1296
  end
771
1297
 
772
- def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
1298
+ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
773
1299
  prompt_name = request[:name]
774
1300
  prompt = @prompts[prompt_name]
775
1301
  unless prompt
776
1302
  add_instrumentation_data(error: :prompt_not_found)
777
- raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found)
1303
+ # The explicit `error_code` maps an unknown prompt to Invalid Params (-32602) rather than
1304
+ # the default Internal Error (-32603), matching the `tools/call`, `resources/read`, and
1305
+ # `completion/complete` siblings for the same not-found condition, while `error_type:
1306
+ # :prompt_not_found` keeps the descriptive message and instrumentation label.
1307
+ raise RequestHandlerError.new(
1308
+ "Prompt not found #{prompt_name}",
1309
+ request,
1310
+ error_type: :prompt_not_found,
1311
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
1312
+ )
778
1313
  end
779
1314
 
780
1315
  add_instrumentation_data(prompt_name: prompt_name)
@@ -787,17 +1322,34 @@ module MCP
787
1322
  session: session,
788
1323
  related_request_id: related_request_id,
789
1324
  cancellation: cancellation,
1325
+ envelope: envelope,
790
1326
  )
791
1327
 
792
1328
  call_prompt_template_with_args(prompt, prompt_args, server_context)
793
1329
  end
794
1330
 
795
- def list_resources(request)
796
- 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)
797
1339
 
798
1340
  apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
799
1341
  end
800
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
+
801
1353
  # Default `resources/read` handler: routes to class-based resources and resource templates.
802
1354
  # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
803
1355
  # unknown URIs keep the historical no-op `[]` response instead of raising.
@@ -871,18 +1423,19 @@ module MCP
871
1423
  end
872
1424
 
873
1425
  # Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added
874
- # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in
875
- # which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`).
1426
+ # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields,
1427
+ # in which case the missing one is filled with `ttlMs: 0` (do not cache) or `cacheScope: "private"`,
1428
+ # the side that cannot leak a user-dependent result through a shared cache (the TypeScript SDK's default).
876
1429
  # Values already in the result win, enabling per-result overrides.
877
1430
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
878
1431
  def apply_cache_metadata(result)
879
1432
  explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
880
1433
  return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
881
1434
 
882
- { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
1435
+ { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
883
1436
  end
884
1437
 
885
- def complete(params, session: nil, related_request_id: nil, cancellation: nil)
1438
+ def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
886
1439
  validate_completion_params!(params)
887
1440
 
888
1441
  result = dispatch_optional_context_handler(
@@ -891,6 +1444,7 @@ module MCP
891
1444
  session: session,
892
1445
  related_request_id: related_request_id,
893
1446
  cancellation: cancellation,
1447
+ envelope: envelope,
894
1448
  )
895
1449
 
896
1450
  normalize_completion_result(result)
@@ -899,13 +1453,14 @@ module MCP
899
1453
  # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
900
1454
  # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
901
1455
  # `server_context.raise_if_cancelled!`.
902
- def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
1456
+ def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
903
1457
  dispatch_optional_context_handler(
904
1458
  @handlers[Methods::RESOURCES_READ],
905
1459
  request,
906
1460
  session: session,
907
1461
  related_request_id: related_request_id,
908
1462
  cancellation: cancellation,
1463
+ envelope: envelope,
909
1464
  )
910
1465
  end
911
1466
 
@@ -913,7 +1468,7 @@ module MCP
913
1468
  # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
914
1469
  # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
915
1470
  # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
916
- def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
1471
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
917
1472
  return handler.call(params) unless handler_declares_server_context?(handler)
918
1473
 
919
1474
  server_context = build_server_context(
@@ -921,6 +1476,7 @@ module MCP
921
1476
  session: session,
922
1477
  related_request_id: related_request_id,
923
1478
  cancellation: cancellation,
1479
+ envelope: envelope,
924
1480
  )
925
1481
  handler.call(params, server_context: server_context)
926
1482
  end
@@ -945,16 +1501,20 @@ module MCP
945
1501
 
946
1502
  # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
947
1503
  # (progress, cancellation, nested server-to-client requests).
948
- def build_server_context(request:, session:, related_request_id:, cancellation:)
1504
+ def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
949
1505
  meta_source = request.is_a?(Hash) ? request : {}
950
1506
  progress_token = meta_source.dig(:_meta, :progressToken)
951
1507
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
1508
+ retry_fields = mrtr_retry_fields(meta_source)
952
1509
  ServerContext.new(
953
1510
  server_context_with_meta(meta_source),
954
1511
  progress: progress,
955
1512
  notification_target: session,
956
1513
  related_request_id: related_request_id,
957
1514
  cancellation: cancellation,
1515
+ envelope: envelope,
1516
+ input_responses: retry_fields[:input_responses],
1517
+ request_state: retry_fields[:request_state],
958
1518
  )
959
1519
  end
960
1520
 
@@ -986,14 +1546,18 @@ module MCP
986
1546
 
987
1547
  # Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
988
1548
  # Clients on older protocol versions may only read `content`,
989
- # so when a tool returns non-object structured content without providing
990
- # any content blocks, mirror the value into `content` as serialized JSON text.
991
- def serialize_structured_content_fallback(result)
1549
+ # so when a tool returns non-object structured content without explicitly
1550
+ # providing `content`, mirror the value into serialized JSON text.
1551
+ def serialize_structured_content_fallback(result, content_provided: false)
992
1552
  structured = result[:structuredContent]
993
1553
  return result if structured.nil? || structured.is_a?(Hash)
1554
+ return result if content_provided
994
1555
  return result unless result[:content].nil? || result[:content].empty?
995
1556
 
996
- result.merge(content: [{ type: "text", text: JSON.generate(structured) }])
1557
+ serialized = JSON.generate(structured)
1558
+ return result if JSON.parse(serialized).is_a?(Hash)
1559
+
1560
+ result.merge(content: [{ type: "text", text: serialized }])
997
1561
  end
998
1562
 
999
1563
  # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
@@ -1010,11 +1574,11 @@ module MCP
1010
1574
  end
1011
1575
  end
1012
1576
 
1013
- def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
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)
1014
1578
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1015
1579
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1016
1580
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
1017
- # See docs/building-servers.md ("Tool argument keys").
1581
+ # See docs/server/tools.md ("Tool argument keys").
1018
1582
  args = arguments&.transform_keys(&:to_sym) || {}
1019
1583
 
1020
1584
  if accepts_server_context?(tool.method(:call))
@@ -1025,19 +1589,24 @@ module MCP
1025
1589
  notification_target: session,
1026
1590
  related_request_id: related_request_id,
1027
1591
  cancellation: cancellation,
1592
+ envelope: envelope,
1593
+ input_responses: retry_fields&.fetch(:input_responses, nil),
1594
+ request_state: retry_fields&.fetch(:request_state, nil),
1028
1595
  )
1029
- tool.call(**args, server_context: server_context).to_h
1596
+ tool.call(**args, server_context: server_context)
1030
1597
  else
1031
- tool.call(**args).to_h
1598
+ tool.call(**args)
1032
1599
  end
1033
1600
  end
1034
1601
 
1035
1602
  def call_prompt_template_with_args(prompt, args, server_context)
1036
- if accepts_server_context?(prompt.method(:template))
1037
- prompt.template(args, server_context: server_context).to_h
1603
+ raw_result = if accepts_server_context?(prompt.method(:template))
1604
+ prompt.template(args, server_context: server_context)
1038
1605
  else
1039
- prompt.template(args).to_h
1606
+ prompt.template(args)
1040
1607
  end
1608
+
1609
+ raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
1041
1610
  end
1042
1611
 
1043
1612
  def server_context_with_meta(request)