mcp 1.1.0 → 1.2.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
@@ -65,13 +71,15 @@ module MCP
65
71
  #
66
72
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
67
73
  class UnsupportedProtocolVersionError < RequestHandlerError
68
- def initialize(requested, request = nil, supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS)
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)
69
77
  super(
70
78
  "Unsupported protocol version",
71
79
  request,
72
80
  error_type: :unsupported_protocol_version,
73
81
  error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
74
- error_data: { supported: supported, requested: requested || "unknown" },
82
+ error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" },
75
83
  )
76
84
  end
77
85
  end
@@ -120,6 +128,27 @@ module MCP
120
128
  end
121
129
  end
122
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
+
123
152
  class MethodAlreadyDefinedError < StandardError
124
153
  attr_reader :method_name
125
154
 
@@ -140,8 +169,20 @@ module MCP
140
169
  # Allowed values for the SEP-2549 `cacheScope` cache hint.
141
170
  CACHE_SCOPES = ["public", "private"].freeze
142
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
+
143
184
  attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
144
- 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
145
186
 
146
187
  def initialize(
147
188
  description: nil,
@@ -161,6 +202,8 @@ module MCP
161
202
  page_size: nil,
162
203
  ttl_ms: nil,
163
204
  cache_scope: nil,
205
+ request_state_security: nil,
206
+ input_required_legacy_shim: true,
164
207
  transport: nil
165
208
  )
166
209
  @description = description
@@ -180,8 +223,16 @@ module MCP
180
223
  self.page_size = page_size
181
224
  self.ttl_ms = ttl_ms
182
225
  self.cache_scope = cache_scope
226
+ @request_state_security = request_state_security
227
+
228
+ # Dual-era authoring (SEP-2322): on the legacy wire, an `input_required` result is fulfilled
229
+ # through real server-to-client requests and the handler re-runs, so handlers written
230
+ # in the 2026 style serve both eras. `false` restores the strict rejection of `input_required`
231
+ # on legacy requests. Matches the TypeScript SDK's default-on legacy shim.
232
+ @input_required_legacy_shim = input_required_legacy_shim
183
233
  @configuration = MCP.configuration.merge(configuration)
184
234
  @client = nil
235
+ @client_protocol_version = nil
185
236
 
186
237
  validate!
187
238
 
@@ -545,7 +596,26 @@ module MCP
545
596
  return
546
597
  end
547
598
 
548
- Methods.ensure_capability!(method, capabilities)
599
+ # SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found
600
+ # there regardless of the server's declared capabilities: in this era the method does not exist
601
+ # at all, which is why the check precedes `ensure_capability!`.
602
+ if Methods::MODERN_REMOVED_METHODS.include?(method) && modern_request?(request, session)
603
+ raise RequestHandlerError.new(
604
+ "Method not found: #{method} is not part of the modern lifecycle (SEP-2575)",
605
+ request,
606
+ error_type: :method_not_found,
607
+ error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
608
+ )
609
+ end
610
+
611
+ begin
612
+ Methods.ensure_capability!(method, capabilities)
613
+ rescue Methods::MissingRequiredCapabilityError => e
614
+ # Re-raise through RequestHandlerError, the one channel whose message is
615
+ # deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError`
616
+ # no longer echoes exception messages into the error `data` member (CWE-209).
617
+ raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e)
618
+ end
549
619
 
550
620
  # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
551
621
  # so do not track it in the in-flight registry.
@@ -560,25 +630,41 @@ module MCP
560
630
  server_context: { request: request },
561
631
  exception_already_reported: ->(e) { reported_exception.equal?(e) },
562
632
  ) do
633
+ envelope = lift_request_envelope(params, method: method, session: session)
634
+
635
+ # The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle:
636
+ # it authorizes `notifications/message` for this request only (SEP-2575). Without it,
637
+ # `ServerSession#notify_log_message` stays silent on modern-era sessions. An unrecognized level
638
+ # reads as absent, so delivery stays off (the safe direction), matching the Python SDK.
639
+ if envelope&.log_level && session.respond_to?(:configure_logging)
640
+ request_logging = LoggingMessageNotification.new(level: envelope.log_level)
641
+ session.configure_logging(request_logging) if request_logging.valid_level?
642
+ end
643
+
644
+ params = unseal_request_state(params, method: method) if @request_state_security
645
+
563
646
  result = case method
564
647
  when Methods::INITIALIZE
565
648
  init(params, session: session)
566
649
  when Methods::RESOURCES_READ
567
- build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation))
650
+ contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
651
+
652
+ # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
653
+ contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
568
654
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
569
655
  validate_resource_subscription_params!(params)
570
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
656
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
571
657
  {}
572
658
  when Methods::TOOLS_CALL
573
- call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
659
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
574
660
  when Methods::PROMPTS_GET
575
- get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
661
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
576
662
  when Methods::COMPLETION_COMPLETE
577
- complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
663
+ complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
578
664
  when Methods::LOGGING_SET_LEVEL
579
665
  configure_logging_level(params, session: session)
580
666
  else
581
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
667
+ dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
582
668
  end
583
669
  client = session&.client || @client
584
670
  add_instrumentation_data(client: client) if client
@@ -588,6 +674,43 @@ module MCP
588
674
  next JsonRpcHandler::NO_RESPONSE
589
675
  end
590
676
 
677
+ # Runs after the cancellation check so a cancelled request stays suppressed
678
+ # instead of turning into a gate error response.
679
+ if result.is_a?(InputRequiredResult)
680
+ result = if envelope.nil? && @input_required_legacy_shim && session
681
+ run_legacy_input_required_shim(
682
+ result,
683
+ method: method,
684
+ params: params,
685
+ session: session,
686
+ related_request_id: related_request_id,
687
+ cancellation: cancellation,
688
+ )
689
+ else
690
+ serialize_input_required_result(result, envelope: envelope, request: params, method: method)
691
+ end
692
+ end
693
+
694
+ # SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns;
695
+ # a missing value is only tolerated FROM earlier protocol versions. Results that already carry
696
+ # a discriminator keep it; everything else on the modern wire is the standard shape,
697
+ # `"complete"`. Legacy results stay unstamped: pre-2026 clients do not know the field.
698
+ if envelope && result.is_a?(Hash) && !result.key?(:resultType)
699
+ result = result.merge(resultType: ResultType::COMPLETE)
700
+ end
701
+
702
+ # SEP-2549 makes the `ttlMs`/`cacheScope` hints REQUIRED on cacheable results at 2026-07-28,
703
+ # so unconfigured servers get `ttlMs: 0` (do not cache) and `cacheScope: "private"`:
704
+ # the spec names no default scope, and `"private"` is the side that cannot leak
705
+ # a user-dependent result through a shared cache, matching the TypeScript SDK's default
706
+ # and `server/discover`. Values already in the result win.
707
+ # Only complete results are cacheable: an SEP-2322 `input_required` round trip must not be stamped
708
+ # (the stamp above guarantees `resultType` is present on every modern Hash result by this point).
709
+ if envelope && result.is_a?(Hash) && CACHEABLE_RESULT_METHODS.include?(method) &&
710
+ result[:resultType] == ResultType::COMPLETE && !(result.key?(:ttlMs) && result.key?(:cacheScope))
711
+ result = { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
712
+ end
713
+
591
714
  result
592
715
  rescue CancelledError => e
593
716
  add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
@@ -609,6 +732,259 @@ module MCP
609
732
  }
610
733
  end
611
734
 
735
+ # Whether this request belongs to the modern lifecycle, used to refuse the RPCs SEP-2575 removed from it.
736
+ # A locked `ServerSession#era` is authoritative; until it locks, the request's own `_meta` envelope is
737
+ # the signal. Both are needed because `StdioTransport` locks the era only after a response succeeds,
738
+ # which would otherwise let the first request of a connection reach a method the modern lifecycle does not have.
739
+ # The Python SDK gates the same way, on the envelope of each request rather than on connection state that
740
+ # is only settled afterwards.
741
+ #
742
+ # A legacy-locked session is deliberately excluded: `lift_request_envelope` answers a modern envelope there
743
+ # with the lifecycle violation, which names the cause better than Method not found.
744
+ def modern_request?(request, session)
745
+ era = session.respond_to?(:era) ? session.era : nil
746
+ return true if era == :modern
747
+ return false unless era.nil?
748
+
749
+ RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil)
750
+ end
751
+
752
+ # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
753
+ # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
754
+ # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
755
+ # pre-version discovery, so both are exempt. Era-locked sessions additionally enforce the dual-era rules:
756
+ # on a modern session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake)
757
+ # and the triple becomes required for every other request; on a legacy session, a modern envelope is rejected as
758
+ # an invalid request because a connection can never change eras.
759
+ def lift_request_envelope(params, method:, session:)
760
+ return if Methods.notification?(method)
761
+
762
+ era = session.respond_to?(:era) ? session.era : nil
763
+
764
+ # Outside the modern era, `server/discover` stays envelope-exempt so legacy connections can probe capabilities
765
+ # before any negotiation. Under the modern era every request carries the envelope, discovery included:
766
+ # the conformance suite's 2026-07-28 requirements reject an envelope-less `server/discover` with `-32602` (SEP-2575).
767
+ return if method == Methods::SERVER_DISCOVER && era != :modern
768
+
769
+ if RequestEnvelope.modern?(params)
770
+ if era == :legacy
771
+ raise RequestHandlerError.new(
772
+ "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
773
+ params,
774
+ error_type: :invalid_request,
775
+ )
776
+ end
777
+
778
+ RequestEnvelope.parse!(params, request: params)
779
+ elsif era == :modern
780
+ # A claim-less request on a modern session is a malformed envelope, not a malformed request:
781
+ # the spec maps missing required envelope fields to Invalid params (`-32602`),
782
+ # and the reference SDKs answer it naming the missing keys.
783
+ raise RequestHandlerError.new(
784
+ "Invalid params: missing or invalid `#{RequestEnvelope::REQUIRED_META_KEYS.join("`, `")}` in `_meta`",
785
+ params,
786
+ error_type: :invalid_params,
787
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
788
+ )
789
+ end
790
+ end
791
+
792
+ # Central gate and serializer for SEP-2322 `input_required` results, run once in the dispatch lambda for
793
+ # whichever handler produced one. The result type exists only in the 2026-07-28 stateless lifecycle,
794
+ # so a legacy request (no envelope) must not receive it: pre-2026 clients treat an unknown `resultType` as
795
+ # a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on
796
+ # (or embed requests for) capabilities the client did not declare, and reports every missing capability at
797
+ # once so the client sees the full set.
798
+ def serialize_input_required_result(result, envelope:, request:, method:)
799
+ if envelope.nil?
800
+ raise RequestHandlerError.new(
801
+ "input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)",
802
+ request,
803
+ error_type: :internal_error,
804
+ )
805
+ end
806
+
807
+ missing = result.missing_client_capabilities(envelope.client_capabilities)
808
+ raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty?
809
+
810
+ add_instrumentation_data(input_required: true)
811
+ serialized = result.to_h
812
+
813
+ if @request_state_security && serialized[:requestState]
814
+ serialized = serialized.merge(requestState: @request_state_security.seal(
815
+ serialized[:requestState],
816
+ method: method,
817
+ target: mrtr_target(request),
818
+ arguments_digest: mrtr_arguments_digest(request),
819
+ ))
820
+ end
821
+
822
+ serialized
823
+ end
824
+
825
+ # Methods whose results may be `input_required` and whose retried requests carry
826
+ # `inputResponses`/`requestState` (SEP-2322).
827
+ MRTR_METHODS = [Methods::TOOLS_CALL, Methods::PROMPTS_GET, Methods::RESOURCES_READ].freeze
828
+
829
+ # Fulfilment rounds the legacy shim runs before giving up, matching the TypeScript SDK's legacy shim default (`maxRounds: 8`).
830
+ LEGACY_INPUT_REQUIRED_MAX_ROUNDS = 8
831
+
832
+ # Dual-era authoring shim (SEP-2322): a handler on the legacy wire returned an `input_required` result,
833
+ # which pre-2026 clients cannot understand, so the server fulfills it in place of the client's driver.
834
+ # Every entry of `inputRequests` is sent as the equivalent real server-to-client request (associated with
835
+ # the originating request per SEP-2260), the answers are collected under the same keys, and the handler
836
+ # re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay
837
+ # contract the modern client driver follows. The `requestState` round-trips in-process as the raw value
838
+ # the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply.
839
+ def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:)
840
+ rounds = 0
841
+
842
+ loop do
843
+ missing = result.missing_client_capabilities(session.client_capabilities)
844
+ unless missing.empty?
845
+ # The explicit `error_code` keeps the descriptive message in the JSON-RPC error response
846
+ # (the `ResourceNotFoundError` pattern). `-32021` is a 2026-07-28 code, so the legacy wire
847
+ # gets a plain internal error.
848
+ raise RequestHandlerError.new(
849
+ "input_required requires client capabilities the client did not declare: #{missing.to_json}",
850
+ params,
851
+ error_type: :internal_error,
852
+ error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
853
+ )
854
+ end
855
+
856
+ responses = (result.input_requests || {}).each_with_object({}) do |(key, entry), collected|
857
+ collected[key] = session.fulfill_input_request(
858
+ entry[:method],
859
+ legacy_leg_params(entry),
860
+ related_request_id: related_request_id,
861
+ )
862
+ end
863
+
864
+ retry_params = params.reject { |key, _| [:inputResponses, :requestState].include?(key.to_sym) }
865
+ retry_params[:inputResponses] = responses unless responses.empty?
866
+ retry_params[:requestState] = result.request_state if result.request_state
867
+
868
+ result = redispatch_mrtr_method(
869
+ method,
870
+ retry_params,
871
+ session: session,
872
+ related_request_id: related_request_id,
873
+ cancellation: cancellation,
874
+ )
875
+ return result unless result.is_a?(InputRequiredResult)
876
+
877
+ rounds += 1
878
+ next if rounds < LEGACY_INPUT_REQUIRED_MAX_ROUNDS
879
+
880
+ raise RequestHandlerError.new(
881
+ "Handler still returned `input_required` after #{LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds (SEP-2322)",
882
+ params,
883
+ error_type: :internal_error,
884
+ error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
885
+ )
886
+ end
887
+ end
888
+
889
+ # The params an embedded entry sends on its legacy leg. Entries are forwarded verbatim with
890
+ # one exception: the 2025-11-25 wire requires `elicitationId` on URL-mode elicitation requests,
891
+ # a field the 2026-07-28 in-band shape dropped (correlation rides `requestState` there),
892
+ # so a missing one is synthesized for the leg, matching the TypeScript SDK's shim.
893
+ def legacy_leg_params(entry)
894
+ params = entry[:params]
895
+ return params unless entry[:method] == Methods::ELICITATION_CREATE
896
+ return params if params.nil? || (params[:mode] || params["mode"]) != "url"
897
+ return params if params.key?(:elicitationId) || params.key?("elicitationId")
898
+
899
+ params.merge(elicitationId: SecureRandom.uuid)
900
+ end
901
+
902
+ # Re-runs the handler of one of the three MRTR-capable methods for
903
+ # the legacy shim. Legacy wire, so no envelope is threaded.
904
+ def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:)
905
+ case method
906
+ when Methods::TOOLS_CALL
907
+ call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
908
+ when Methods::PROMPTS_GET
909
+ get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
910
+ when Methods::RESOURCES_READ
911
+ contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
912
+ contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
913
+ else
914
+ raise RequestHandlerError.new(
915
+ "input_required results are only supported for #{MRTR_METHODS.join(", ")}",
916
+ params,
917
+ error_type: :internal_error,
918
+ )
919
+ end
920
+ end
921
+
922
+ # Replaces a sealed client-echoed `requestState` with its verified plaintext before dispatch,
923
+ # so handlers always read the state they wrote. A tampered, expired, or cross-request token is
924
+ # rejected as invalid params, matching the Python SDK's "Invalid or expired requestState" behavior.
925
+ def unseal_request_state(params, method:)
926
+ return params unless MRTR_METHODS.include?(method)
927
+ return params unless params.is_a?(Hash)
928
+
929
+ sealed = params[:requestState] || params["requestState"]
930
+ return params unless sealed
931
+
932
+ plaintext = @request_state_security.unseal(
933
+ sealed,
934
+ method: method,
935
+ target: mrtr_target(params),
936
+ arguments_digest: mrtr_arguments_digest(params),
937
+ )
938
+ key = params.key?("requestState") ? "requestState" : :requestState
939
+ params.merge(key => plaintext)
940
+ rescue RequestStateSecurity::InvalidStateError => e
941
+ raise RequestHandlerError.new(
942
+ "Invalid or expired requestState",
943
+ params,
944
+ error_type: :invalid_params,
945
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
946
+ original_error: e,
947
+ )
948
+ end
949
+
950
+ def mrtr_target(params)
951
+ return "" unless params.is_a?(Hash)
952
+
953
+ params[:name] || params["name"] || params[:uri] || params["uri"] || ""
954
+ end
955
+
956
+ # Digest of the originating arguments, binding a sealed state to retries of
957
+ # the same call with the same inputs. Keys are stringified and sorted recursively
958
+ # so symbol/string parses of identical JSON digest identically.
959
+ def mrtr_arguments_digest(params)
960
+ arguments = params.is_a?(Hash) ? params[:arguments] || params["arguments"] : nil
961
+ OpenSSL::Digest::SHA256.hexdigest(canonical_json(arguments || {}))
962
+ end
963
+
964
+ def canonical_json(value)
965
+ case value
966
+ when Hash
967
+ pairs = value.map { |key, nested| [key.to_s, nested] }.sort_by(&:first)
968
+ "{#{pairs.map { |key, nested| "#{key.to_json}:#{canonical_json(nested)}" }.join(",")}}"
969
+ when Array
970
+ "[#{value.map { |element| canonical_json(element) }.join(",")}]"
971
+ else
972
+ value.to_json
973
+ end
974
+ end
975
+
976
+ # Extracts the SEP-2322 retry fields a client sends when re-issuing a request:
977
+ # `inputResponses` (answers keyed like the earlier `inputRequests`) and the echoed opaque `requestState`.
978
+ # They are params-top-level siblings of `name`/`arguments`/ `uri`, not `_meta` entries.
979
+ def mrtr_retry_fields(params)
980
+ return { input_responses: nil, request_state: nil } unless params.is_a?(Hash)
981
+
982
+ {
983
+ input_responses: params[:inputResponses] || params["inputResponses"],
984
+ request_state: params[:requestState] || params["requestState"],
985
+ }
986
+ end
987
+
612
988
  def handle_cancelled_notification(params, session: nil)
613
989
  return unless session
614
990
  return unless params.is_a?(Hash)
@@ -658,10 +1034,19 @@ module MCP
658
1034
  end
659
1035
  protocol_version = params[:protocolVersion]
660
1036
 
661
- negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
1037
+ # Per the SEP-2575 era model, `initialize` negotiates legacy protocol versions only: a modern version is
1038
+ # defined by carrying its own version on every request in `_meta`, with no handshake,
1039
+ # so asking `initialize` for one (or for a version this server does not know) is answered with
1040
+ # a counter-offer instead of an echo, matching the TypeScript and Python SDKs. Modern versions
1041
+ # stay reachable through `server/discover` and the per-request envelope. The counter-offer is
1042
+ # a configured `protocol_version` pin when one is set (`Configuration` only accepts handshake versions there),
1043
+ # and the latest handshake version otherwise.
1044
+ negotiated_version = if Configuration.handshake_protocol_version?(protocol_version)
662
1045
  protocol_version
663
- else
1046
+ elsif configuration.protocol_version?
664
1047
  configuration.protocol_version
1048
+ else
1049
+ Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
665
1050
  end
666
1051
 
667
1052
  info = server_info.reject do |property|
@@ -675,7 +1060,11 @@ module MCP
675
1060
  response_instructions = nil
676
1061
  end
677
1062
 
678
- session&.mark_initialized!
1063
+ if session
1064
+ session.mark_initialized!(protocol_version: negotiated_version)
1065
+ else
1066
+ @client_protocol_version = negotiated_version
1067
+ end
679
1068
 
680
1069
  {
681
1070
  protocolVersion: negotiated_version,
@@ -710,20 +1099,47 @@ module MCP
710
1099
  end
711
1100
  end
712
1101
 
713
- # Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery.
1102
+ # Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery.
714
1103
  # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
715
1104
  # the session initialized, and responds regardless of capability declarations or initialization state,
716
- # so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered
717
- # because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints
718
- # are not included here yet.
1105
+ # so clients can probe a server before (or instead of) `initialize`.
1106
+ #
1107
+ # `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
1108
+ # legacy versions are negotiated via `initialize`, not selected from discovery. The `ttlMs`/`cacheScope`
1109
+ # cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list/read results),
1110
+ # so the spec defaults (`0` = immediately stale, `"private"` = per-authorization-context caching only)
1111
+ # fill in when the server was not configured with `ttl_ms`/`cache_scope`. The server identity rides
1112
+ # in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp per the finalized
1113
+ # spec (PR #3002), unfiltered because discovery happens before version negotiation.
719
1114
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
720
1115
  def discover(_request)
721
1116
  {
722
- supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS,
723
- capabilities: capabilities,
724
- serverInfo: server_info,
1117
+ supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
1118
+ capabilities: discover_capabilities,
725
1119
  instructions: instructions,
726
- }.compact
1120
+ _meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info },
1121
+ }.compact.merge(
1122
+ ttlMs: ttl_ms || 0,
1123
+ cacheScope: cache_scope || "private",
1124
+ # `server/discover` is exempt from the `_meta` envelope, so the central modern-result stamping does not see it;
1125
+ # `DiscoverResult` is still a 2026-07-28 result and carries the REQUIRED `resultType` directly.
1126
+ resultType: ResultType::COMPLETE,
1127
+ )
1128
+ end
1129
+
1130
+ # Capabilities as advertised by `server/discover`. In the modern lifecycle, `listChanged` and `subscribe` flags
1131
+ # promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC
1132
+ # (e.g. stdio), matching the Python SDK's era-aware capability derivation.
1133
+ def discover_capabilities
1134
+ return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
1135
+
1136
+ capabilities.each_with_object({}) do |(name, value), stripped|
1137
+ stripped[name] = if value.is_a?(Hash)
1138
+ value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) }
1139
+ else
1140
+ value
1141
+ end
1142
+ end
727
1143
  end
728
1144
 
729
1145
  def configure_logging_level(request, session: nil)
@@ -748,7 +1164,7 @@ module MCP
748
1164
  apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
749
1165
  end
750
1166
 
751
- def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
1167
+ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
752
1168
  tool_name = request[:name]
753
1169
 
754
1170
  tool = tools[tool_name]
@@ -781,8 +1197,21 @@ module MCP
781
1197
  progress_token = request.dig(:_meta, :progressToken)
782
1198
 
783
1199
  response = call_tool_with_args(
784
- tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
1200
+ tool,
1201
+ arguments,
1202
+ server_context_with_meta(request),
1203
+ progress_token: progress_token,
1204
+ session: session,
1205
+ related_request_id: related_request_id,
1206
+ cancellation: cancellation,
1207
+ envelope: envelope,
1208
+ retry_fields: mrtr_retry_fields(request),
785
1209
  )
1210
+ # An SEP-2322 `input_required` result is not a tool result: output schema
1211
+ # validation would run against a `nil` `structuredContent` and the structured
1212
+ # content fallback does not apply. The dispatch lambda serializes it.
1213
+ return response if response.is_a?(InputRequiredResult)
1214
+
786
1215
  result = response.to_h
787
1216
  validate_tool_call_result!(tool, result)
788
1217
  serialize_structured_content_fallback(
@@ -794,8 +1223,11 @@ module MCP
794
1223
  # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
795
1224
  raise
796
1225
  rescue => e
1226
+ # `e.message` is deliberately not included: it can carry internals (class, method
1227
+ # and host names) that must not reach untrusted clients (CWE-209). The original
1228
+ # exception still reaches `configuration.exception_reporter` via `original_error`.
797
1229
  raise RequestHandlerError.new(
798
- "Internal error calling tool #{tool_name}: #{e.message}",
1230
+ "Internal error calling tool #{tool_name}",
799
1231
  request,
800
1232
  error_type: :internal_error,
801
1233
  original_error: e,
@@ -808,12 +1240,21 @@ module MCP
808
1240
  apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
809
1241
  end
810
1242
 
811
- def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
1243
+ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
812
1244
  prompt_name = request[:name]
813
1245
  prompt = @prompts[prompt_name]
814
1246
  unless prompt
815
1247
  add_instrumentation_data(error: :prompt_not_found)
816
- raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found)
1248
+ # The explicit `error_code` maps an unknown prompt to Invalid Params (-32602) rather than
1249
+ # the default Internal Error (-32603), matching the `tools/call`, `resources/read`, and
1250
+ # `completion/complete` siblings for the same not-found condition, while `error_type:
1251
+ # :prompt_not_found` keeps the descriptive message and instrumentation label.
1252
+ raise RequestHandlerError.new(
1253
+ "Prompt not found #{prompt_name}",
1254
+ request,
1255
+ error_type: :prompt_not_found,
1256
+ error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
1257
+ )
817
1258
  end
818
1259
 
819
1260
  add_instrumentation_data(prompt_name: prompt_name)
@@ -826,6 +1267,7 @@ module MCP
826
1267
  session: session,
827
1268
  related_request_id: related_request_id,
828
1269
  cancellation: cancellation,
1270
+ envelope: envelope,
829
1271
  )
830
1272
 
831
1273
  call_prompt_template_with_args(prompt, prompt_args, server_context)
@@ -910,18 +1352,19 @@ module MCP
910
1352
  end
911
1353
 
912
1354
  # Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added
913
- # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in
914
- # which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`).
1355
+ # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields,
1356
+ # in which case the missing one is filled with `ttlMs: 0` (do not cache) or `cacheScope: "private"`,
1357
+ # the side that cannot leak a user-dependent result through a shared cache (the TypeScript SDK's default).
915
1358
  # Values already in the result win, enabling per-result overrides.
916
1359
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
917
1360
  def apply_cache_metadata(result)
918
1361
  explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
919
1362
  return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
920
1363
 
921
- { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
1364
+ { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
922
1365
  end
923
1366
 
924
- def complete(params, session: nil, related_request_id: nil, cancellation: nil)
1367
+ def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
925
1368
  validate_completion_params!(params)
926
1369
 
927
1370
  result = dispatch_optional_context_handler(
@@ -930,6 +1373,7 @@ module MCP
930
1373
  session: session,
931
1374
  related_request_id: related_request_id,
932
1375
  cancellation: cancellation,
1376
+ envelope: envelope,
933
1377
  )
934
1378
 
935
1379
  normalize_completion_result(result)
@@ -938,13 +1382,14 @@ module MCP
938
1382
  # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
939
1383
  # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
940
1384
  # `server_context.raise_if_cancelled!`.
941
- def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
1385
+ def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
942
1386
  dispatch_optional_context_handler(
943
1387
  @handlers[Methods::RESOURCES_READ],
944
1388
  request,
945
1389
  session: session,
946
1390
  related_request_id: related_request_id,
947
1391
  cancellation: cancellation,
1392
+ envelope: envelope,
948
1393
  )
949
1394
  end
950
1395
 
@@ -952,7 +1397,7 @@ module MCP
952
1397
  # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
953
1398
  # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
954
1399
  # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
955
- def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
1400
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
956
1401
  return handler.call(params) unless handler_declares_server_context?(handler)
957
1402
 
958
1403
  server_context = build_server_context(
@@ -960,6 +1405,7 @@ module MCP
960
1405
  session: session,
961
1406
  related_request_id: related_request_id,
962
1407
  cancellation: cancellation,
1408
+ envelope: envelope,
963
1409
  )
964
1410
  handler.call(params, server_context: server_context)
965
1411
  end
@@ -984,16 +1430,20 @@ module MCP
984
1430
 
985
1431
  # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
986
1432
  # (progress, cancellation, nested server-to-client requests).
987
- def build_server_context(request:, session:, related_request_id:, cancellation:)
1433
+ def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
988
1434
  meta_source = request.is_a?(Hash) ? request : {}
989
1435
  progress_token = meta_source.dig(:_meta, :progressToken)
990
1436
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
1437
+ retry_fields = mrtr_retry_fields(meta_source)
991
1438
  ServerContext.new(
992
1439
  server_context_with_meta(meta_source),
993
1440
  progress: progress,
994
1441
  notification_target: session,
995
1442
  related_request_id: related_request_id,
996
1443
  cancellation: cancellation,
1444
+ envelope: envelope,
1445
+ input_responses: retry_fields[:input_responses],
1446
+ request_state: retry_fields[:request_state],
997
1447
  )
998
1448
  end
999
1449
 
@@ -1053,7 +1503,7 @@ module MCP
1053
1503
  end
1054
1504
  end
1055
1505
 
1056
- def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
1506
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, retry_fields: nil)
1057
1507
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1058
1508
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1059
1509
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
@@ -1068,6 +1518,9 @@ module MCP
1068
1518
  notification_target: session,
1069
1519
  related_request_id: related_request_id,
1070
1520
  cancellation: cancellation,
1521
+ envelope: envelope,
1522
+ input_responses: retry_fields&.fetch(:input_responses, nil),
1523
+ request_state: retry_fields&.fetch(:request_state, nil),
1071
1524
  )
1072
1525
  tool.call(**args, server_context: server_context)
1073
1526
  else
@@ -1076,11 +1529,13 @@ module MCP
1076
1529
  end
1077
1530
 
1078
1531
  def call_prompt_template_with_args(prompt, args, server_context)
1079
- if accepts_server_context?(prompt.method(:template))
1080
- prompt.template(args, server_context: server_context).to_h
1532
+ raw_result = if accepts_server_context?(prompt.method(:template))
1533
+ prompt.template(args, server_context: server_context)
1081
1534
  else
1082
- prompt.template(args).to_h
1535
+ prompt.template(args)
1083
1536
  end
1537
+
1538
+ raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
1084
1539
  end
1085
1540
 
1086
1541
  def server_context_with_meta(request)