mcp 1.0.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
@@ -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
@@ -145,8 +223,16 @@ module MCP
145
223
  self.page_size = page_size
146
224
  self.ttl_ms = ttl_ms
147
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
148
233
  @configuration = MCP.configuration.merge(configuration)
149
234
  @client = nil
235
+ @client_protocol_version = nil
150
236
 
151
237
  validate!
152
238
 
@@ -510,7 +596,26 @@ module MCP
510
596
  return
511
597
  end
512
598
 
513
- 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
514
619
 
515
620
  # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
516
621
  # so do not track it in the in-flight registry.
@@ -525,25 +630,41 @@ module MCP
525
630
  server_context: { request: request },
526
631
  exception_already_reported: ->(e) { reported_exception.equal?(e) },
527
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
+
528
646
  result = case method
529
647
  when Methods::INITIALIZE
530
648
  init(params, session: session)
531
649
  when Methods::RESOURCES_READ
532
- 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)
533
654
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
534
655
  validate_resource_subscription_params!(params)
535
- 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)
536
657
  {}
537
658
  when Methods::TOOLS_CALL
538
- 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)
539
660
  when Methods::PROMPTS_GET
540
- 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)
541
662
  when Methods::COMPLETION_COMPLETE
542
- 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)
543
664
  when Methods::LOGGING_SET_LEVEL
544
665
  configure_logging_level(params, session: session)
545
666
  else
546
- 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)
547
668
  end
548
669
  client = session&.client || @client
549
670
  add_instrumentation_data(client: client) if client
@@ -553,6 +674,43 @@ module MCP
553
674
  next JsonRpcHandler::NO_RESPONSE
554
675
  end
555
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
+
556
714
  result
557
715
  rescue CancelledError => e
558
716
  add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
@@ -574,6 +732,259 @@ module MCP
574
732
  }
575
733
  end
576
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
+
577
988
  def handle_cancelled_notification(params, session: nil)
578
989
  return unless session
579
990
  return unless params.is_a?(Hash)
@@ -623,10 +1034,19 @@ module MCP
623
1034
  end
624
1035
  protocol_version = params[:protocolVersion]
625
1036
 
626
- 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)
627
1045
  protocol_version
628
- else
1046
+ elsif configuration.protocol_version?
629
1047
  configuration.protocol_version
1048
+ else
1049
+ Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
630
1050
  end
631
1051
 
632
1052
  info = server_info.reject do |property|
@@ -640,7 +1060,11 @@ module MCP
640
1060
  response_instructions = nil
641
1061
  end
642
1062
 
643
- session&.mark_initialized!
1063
+ if session
1064
+ session.mark_initialized!(protocol_version: negotiated_version)
1065
+ else
1066
+ @client_protocol_version = negotiated_version
1067
+ end
644
1068
 
645
1069
  {
646
1070
  protocolVersion: negotiated_version,
@@ -675,20 +1099,47 @@ module MCP
675
1099
  end
676
1100
  end
677
1101
 
678
- # 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.
679
1103
  # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
680
1104
  # 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.
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.
684
1114
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
685
1115
  def discover(_request)
686
1116
  {
687
- supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS,
688
- capabilities: capabilities,
689
- serverInfo: server_info,
1117
+ supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
1118
+ capabilities: discover_capabilities,
690
1119
  instructions: instructions,
691
- }.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
692
1143
  end
693
1144
 
694
1145
  def configure_logging_level(request, session: nil)
@@ -713,7 +1164,7 @@ module MCP
713
1164
  apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
714
1165
  end
715
1166
 
716
- 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)
717
1168
  tool_name = request[:name]
718
1169
 
719
1170
  tool = tools[tool_name]
@@ -745,18 +1196,38 @@ module MCP
745
1196
 
746
1197
  progress_token = request.dig(:_meta, :progressToken)
747
1198
 
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
1199
+ response = call_tool_with_args(
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),
750
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
+
1215
+ result = response.to_h
751
1216
  validate_tool_call_result!(tool, result)
752
- serialize_structured_content_fallback(result)
1217
+ serialize_structured_content_fallback(
1218
+ result,
1219
+ content_provided: response.respond_to?(:content_provided?) && response.content_provided?,
1220
+ )
753
1221
  rescue RequestHandlerError, CancelledError
754
1222
  # CancelledError is intentionally not wrapped so `handle_request` can turn it into
755
1223
  # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
756
1224
  raise
757
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`.
758
1229
  raise RequestHandlerError.new(
759
- "Internal error calling tool #{tool_name}: #{e.message}",
1230
+ "Internal error calling tool #{tool_name}",
760
1231
  request,
761
1232
  error_type: :internal_error,
762
1233
  original_error: e,
@@ -769,12 +1240,21 @@ module MCP
769
1240
  apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
770
1241
  end
771
1242
 
772
- 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)
773
1244
  prompt_name = request[:name]
774
1245
  prompt = @prompts[prompt_name]
775
1246
  unless prompt
776
1247
  add_instrumentation_data(error: :prompt_not_found)
777
- 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
+ )
778
1258
  end
779
1259
 
780
1260
  add_instrumentation_data(prompt_name: prompt_name)
@@ -787,6 +1267,7 @@ module MCP
787
1267
  session: session,
788
1268
  related_request_id: related_request_id,
789
1269
  cancellation: cancellation,
1270
+ envelope: envelope,
790
1271
  )
791
1272
 
792
1273
  call_prompt_template_with_args(prompt, prompt_args, server_context)
@@ -871,18 +1352,19 @@ module MCP
871
1352
  end
872
1353
 
873
1354
  # 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"`).
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).
876
1358
  # Values already in the result win, enabling per-result overrides.
877
1359
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
878
1360
  def apply_cache_metadata(result)
879
1361
  explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
880
1362
  return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
881
1363
 
882
- { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
1364
+ { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
883
1365
  end
884
1366
 
885
- 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)
886
1368
  validate_completion_params!(params)
887
1369
 
888
1370
  result = dispatch_optional_context_handler(
@@ -891,6 +1373,7 @@ module MCP
891
1373
  session: session,
892
1374
  related_request_id: related_request_id,
893
1375
  cancellation: cancellation,
1376
+ envelope: envelope,
894
1377
  )
895
1378
 
896
1379
  normalize_completion_result(result)
@@ -899,13 +1382,14 @@ module MCP
899
1382
  # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
900
1383
  # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
901
1384
  # `server_context.raise_if_cancelled!`.
902
- 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)
903
1386
  dispatch_optional_context_handler(
904
1387
  @handlers[Methods::RESOURCES_READ],
905
1388
  request,
906
1389
  session: session,
907
1390
  related_request_id: related_request_id,
908
1391
  cancellation: cancellation,
1392
+ envelope: envelope,
909
1393
  )
910
1394
  end
911
1395
 
@@ -913,7 +1397,7 @@ module MCP
913
1397
  # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
914
1398
  # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
915
1399
  # 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)
1400
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
917
1401
  return handler.call(params) unless handler_declares_server_context?(handler)
918
1402
 
919
1403
  server_context = build_server_context(
@@ -921,6 +1405,7 @@ module MCP
921
1405
  session: session,
922
1406
  related_request_id: related_request_id,
923
1407
  cancellation: cancellation,
1408
+ envelope: envelope,
924
1409
  )
925
1410
  handler.call(params, server_context: server_context)
926
1411
  end
@@ -945,16 +1430,20 @@ module MCP
945
1430
 
946
1431
  # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
947
1432
  # (progress, cancellation, nested server-to-client requests).
948
- def build_server_context(request:, session:, related_request_id:, cancellation:)
1433
+ def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
949
1434
  meta_source = request.is_a?(Hash) ? request : {}
950
1435
  progress_token = meta_source.dig(:_meta, :progressToken)
951
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)
952
1438
  ServerContext.new(
953
1439
  server_context_with_meta(meta_source),
954
1440
  progress: progress,
955
1441
  notification_target: session,
956
1442
  related_request_id: related_request_id,
957
1443
  cancellation: cancellation,
1444
+ envelope: envelope,
1445
+ input_responses: retry_fields[:input_responses],
1446
+ request_state: retry_fields[:request_state],
958
1447
  )
959
1448
  end
960
1449
 
@@ -986,14 +1475,18 @@ module MCP
986
1475
 
987
1476
  # Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
988
1477
  # 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)
1478
+ # so when a tool returns non-object structured content without explicitly
1479
+ # providing `content`, mirror the value into serialized JSON text.
1480
+ def serialize_structured_content_fallback(result, content_provided: false)
992
1481
  structured = result[:structuredContent]
993
1482
  return result if structured.nil? || structured.is_a?(Hash)
1483
+ return result if content_provided
994
1484
  return result unless result[:content].nil? || result[:content].empty?
995
1485
 
996
- result.merge(content: [{ type: "text", text: JSON.generate(structured) }])
1486
+ serialized = JSON.generate(structured)
1487
+ return result if JSON.parse(serialized).is_a?(Hash)
1488
+
1489
+ result.merge(content: [{ type: "text", text: serialized }])
997
1490
  end
998
1491
 
999
1492
  # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
@@ -1010,7 +1503,7 @@ module MCP
1010
1503
  end
1011
1504
  end
1012
1505
 
1013
- 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)
1014
1507
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1015
1508
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1016
1509
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
@@ -1025,19 +1518,24 @@ module MCP
1025
1518
  notification_target: session,
1026
1519
  related_request_id: related_request_id,
1027
1520
  cancellation: cancellation,
1521
+ envelope: envelope,
1522
+ input_responses: retry_fields&.fetch(:input_responses, nil),
1523
+ request_state: retry_fields&.fetch(:request_state, nil),
1028
1524
  )
1029
- tool.call(**args, server_context: server_context).to_h
1525
+ tool.call(**args, server_context: server_context)
1030
1526
  else
1031
- tool.call(**args).to_h
1527
+ tool.call(**args)
1032
1528
  end
1033
1529
  end
1034
1530
 
1035
1531
  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
1532
+ raw_result = if accepts_server_context?(prompt.method(:template))
1533
+ prompt.template(args, server_context: server_context)
1038
1534
  else
1039
- prompt.template(args).to_h
1535
+ prompt.template(args)
1040
1536
  end
1537
+
1538
+ raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
1041
1539
  end
1042
1540
 
1043
1541
  def server_context_with_meta(request)