mcp 1.1.0 → 1.3.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
@@ -176,12 +219,21 @@ module MCP
176
219
  @resources = resources
177
220
  @resource_templates = resource_templates
178
221
  @resource_index = index_resources_by_uri(resources)
222
+ @resources_list_handler = nil
179
223
  @server_context = server_context
180
224
  self.page_size = page_size
181
225
  self.ttl_ms = ttl_ms
182
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
183
234
  @configuration = MCP.configuration.merge(configuration)
184
235
  @client = nil
236
+ @client_protocol_version = nil
185
237
 
186
238
  validate!
187
239
 
@@ -375,6 +427,22 @@ module MCP
375
427
  @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
376
428
  end
377
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
+
378
446
  # Sets a custom handler for `resources/read` requests.
379
447
  # The block receives the parsed request params and should return resource
380
448
  # contents. The return value is set as the `contents` field of the response.
@@ -395,19 +463,25 @@ module MCP
395
463
  end
396
464
 
397
465
  # Sets a custom handler for `resources/subscribe` requests.
398
- # The block receives the parsed request params. The return value is
399
- # 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`.
400
470
  #
401
471
  # @yield [params] The request params containing `:uri`.
472
+ # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
402
473
  def resources_subscribe_handler(&block)
403
474
  @handlers[Methods::RESOURCES_SUBSCRIBE] = block
404
475
  end
405
476
 
406
477
  # Sets a custom handler for `resources/unsubscribe` requests.
407
- # The block receives the parsed request params. The return value is
408
- # 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`.
409
482
  #
410
483
  # @yield [params] The request params containing `:uri`.
484
+ # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
411
485
  def resources_unsubscribe_handler(&block)
412
486
  @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
413
487
  end
@@ -545,12 +619,47 @@ module MCP
545
619
  return
546
620
  end
547
621
 
548
- 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
549
642
 
550
643
  # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
551
644
  # so do not track it in the in-flight registry.
552
- cancellation = if related_request_id && method != Methods::INITIALIZE
553
- 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
554
663
  end
555
664
 
556
665
  ->(params) {
@@ -560,25 +669,42 @@ module MCP
560
669
  server_context: { request: request },
561
670
  exception_already_reported: ->(e) { reported_exception.equal?(e) },
562
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
+
563
685
  result = case method
564
686
  when Methods::INITIALIZE
565
687
  init(params, session: session)
566
688
  when Methods::RESOURCES_READ
567
- 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)
568
693
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
569
694
  validate_resource_subscription_params!(params)
570
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
571
- {}
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)
572
698
  when Methods::TOOLS_CALL
573
- 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)
574
700
  when Methods::PROMPTS_GET
575
- 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)
576
702
  when Methods::COMPLETION_COMPLETE
577
- 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)
578
704
  when Methods::LOGGING_SET_LEVEL
579
705
  configure_logging_level(params, session: session)
580
706
  else
581
- 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)
582
708
  end
583
709
  client = session&.client || @client
584
710
  add_instrumentation_data(client: client) if client
@@ -588,6 +714,43 @@ module MCP
588
714
  next JsonRpcHandler::NO_RESPONSE
589
715
  end
590
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
+
591
754
  result
592
755
  rescue CancelledError => e
593
756
  add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
@@ -604,11 +767,267 @@ module MCP
604
767
  reported_exception = wrapped
605
768
  raise wrapped
606
769
  ensure
607
- 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
608
774
  end
609
775
  }
610
776
  end
611
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
+
612
1031
  def handle_cancelled_notification(params, session: nil)
613
1032
  return unless session
614
1033
  return unless params.is_a?(Hash)
@@ -658,10 +1077,19 @@ module MCP
658
1077
  end
659
1078
  protocol_version = params[:protocolVersion]
660
1079
 
661
- 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)
662
1088
  protocol_version
663
- else
1089
+ elsif configuration.protocol_version?
664
1090
  configuration.protocol_version
1091
+ else
1092
+ Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
665
1093
  end
666
1094
 
667
1095
  info = server_info.reject do |property|
@@ -675,7 +1103,11 @@ module MCP
675
1103
  response_instructions = nil
676
1104
  end
677
1105
 
678
- session&.mark_initialized!
1106
+ if session
1107
+ session.mark_initialized!(protocol_version: negotiated_version)
1108
+ else
1109
+ @client_protocol_version = negotiated_version
1110
+ end
679
1111
 
680
1112
  {
681
1113
  protocolVersion: negotiated_version,
@@ -691,6 +1123,18 @@ module MCP
691
1123
  end
692
1124
  end
693
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
+
694
1138
  def validate_initialize_params!(params)
695
1139
  unless params.is_a?(Hash)
696
1140
  raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
@@ -710,20 +1154,47 @@ module MCP
710
1154
  end
711
1155
  end
712
1156
 
713
- # 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.
714
1158
  # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
715
1159
  # 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.
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.
719
1169
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
720
1170
  def discover(_request)
721
1171
  {
722
- supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS,
723
- capabilities: capabilities,
724
- serverInfo: server_info,
1172
+ supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
1173
+ capabilities: discover_capabilities,
725
1174
  instructions: instructions,
726
- }.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
727
1198
  end
728
1199
 
729
1200
  def configure_logging_level(request, session: nil)
@@ -748,7 +1219,7 @@ module MCP
748
1219
  apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
749
1220
  end
750
1221
 
751
- 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)
752
1223
  tool_name = request[:name]
753
1224
 
754
1225
  tool = tools[tool_name]
@@ -781,8 +1252,21 @@ module MCP
781
1252
  progress_token = request.dig(:_meta, :progressToken)
782
1253
 
783
1254
  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
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),
785
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
+
786
1270
  result = response.to_h
787
1271
  validate_tool_call_result!(tool, result)
788
1272
  serialize_structured_content_fallback(
@@ -794,8 +1278,11 @@ module MCP
794
1278
  # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
795
1279
  raise
796
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`.
797
1284
  raise RequestHandlerError.new(
798
- "Internal error calling tool #{tool_name}: #{e.message}",
1285
+ "Internal error calling tool #{tool_name}",
799
1286
  request,
800
1287
  error_type: :internal_error,
801
1288
  original_error: e,
@@ -808,12 +1295,21 @@ module MCP
808
1295
  apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
809
1296
  end
810
1297
 
811
- 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)
812
1299
  prompt_name = request[:name]
813
1300
  prompt = @prompts[prompt_name]
814
1301
  unless prompt
815
1302
  add_instrumentation_data(error: :prompt_not_found)
816
- 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
+ )
817
1313
  end
818
1314
 
819
1315
  add_instrumentation_data(prompt_name: prompt_name)
@@ -826,17 +1322,34 @@ module MCP
826
1322
  session: session,
827
1323
  related_request_id: related_request_id,
828
1324
  cancellation: cancellation,
1325
+ envelope: envelope,
829
1326
  )
830
1327
 
831
1328
  call_prompt_template_with_args(prompt, prompt_args, server_context)
832
1329
  end
833
1330
 
834
- def list_resources(request)
835
- 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)
836
1339
 
837
1340
  apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
838
1341
  end
839
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
+
840
1353
  # Default `resources/read` handler: routes to class-based resources and resource templates.
841
1354
  # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
842
1355
  # unknown URIs keep the historical no-op `[]` response instead of raising.
@@ -910,18 +1423,19 @@ module MCP
910
1423
  end
911
1424
 
912
1425
  # 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"`).
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).
915
1429
  # Values already in the result win, enabling per-result overrides.
916
1430
  # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
917
1431
  def apply_cache_metadata(result)
918
1432
  explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
919
1433
  return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
920
1434
 
921
- { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
1435
+ { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
922
1436
  end
923
1437
 
924
- 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)
925
1439
  validate_completion_params!(params)
926
1440
 
927
1441
  result = dispatch_optional_context_handler(
@@ -930,6 +1444,7 @@ module MCP
930
1444
  session: session,
931
1445
  related_request_id: related_request_id,
932
1446
  cancellation: cancellation,
1447
+ envelope: envelope,
933
1448
  )
934
1449
 
935
1450
  normalize_completion_result(result)
@@ -938,13 +1453,14 @@ module MCP
938
1453
  # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
939
1454
  # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
940
1455
  # `server_context.raise_if_cancelled!`.
941
- 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)
942
1457
  dispatch_optional_context_handler(
943
1458
  @handlers[Methods::RESOURCES_READ],
944
1459
  request,
945
1460
  session: session,
946
1461
  related_request_id: related_request_id,
947
1462
  cancellation: cancellation,
1463
+ envelope: envelope,
948
1464
  )
949
1465
  end
950
1466
 
@@ -952,7 +1468,7 @@ module MCP
952
1468
  # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
953
1469
  # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
954
1470
  # 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)
1471
+ def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
956
1472
  return handler.call(params) unless handler_declares_server_context?(handler)
957
1473
 
958
1474
  server_context = build_server_context(
@@ -960,6 +1476,7 @@ module MCP
960
1476
  session: session,
961
1477
  related_request_id: related_request_id,
962
1478
  cancellation: cancellation,
1479
+ envelope: envelope,
963
1480
  )
964
1481
  handler.call(params, server_context: server_context)
965
1482
  end
@@ -984,16 +1501,20 @@ module MCP
984
1501
 
985
1502
  # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
986
1503
  # (progress, cancellation, nested server-to-client requests).
987
- def build_server_context(request:, session:, related_request_id:, cancellation:)
1504
+ def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
988
1505
  meta_source = request.is_a?(Hash) ? request : {}
989
1506
  progress_token = meta_source.dig(:_meta, :progressToken)
990
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)
991
1509
  ServerContext.new(
992
1510
  server_context_with_meta(meta_source),
993
1511
  progress: progress,
994
1512
  notification_target: session,
995
1513
  related_request_id: related_request_id,
996
1514
  cancellation: cancellation,
1515
+ envelope: envelope,
1516
+ input_responses: retry_fields[:input_responses],
1517
+ request_state: retry_fields[:request_state],
997
1518
  )
998
1519
  end
999
1520
 
@@ -1053,11 +1574,11 @@ module MCP
1053
1574
  end
1054
1575
  end
1055
1576
 
1056
- 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)
1057
1578
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1058
1579
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1059
1580
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
1060
- # See docs/building-servers.md ("Tool argument keys").
1581
+ # See docs/server/tools.md ("Tool argument keys").
1061
1582
  args = arguments&.transform_keys(&:to_sym) || {}
1062
1583
 
1063
1584
  if accepts_server_context?(tool.method(:call))
@@ -1068,6 +1589,9 @@ module MCP
1068
1589
  notification_target: session,
1069
1590
  related_request_id: related_request_id,
1070
1591
  cancellation: cancellation,
1592
+ envelope: envelope,
1593
+ input_responses: retry_fields&.fetch(:input_responses, nil),
1594
+ request_state: retry_fields&.fetch(:request_state, nil),
1071
1595
  )
1072
1596
  tool.call(**args, server_context: server_context)
1073
1597
  else
@@ -1076,11 +1600,13 @@ module MCP
1076
1600
  end
1077
1601
 
1078
1602
  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
1603
+ raw_result = if accepts_server_context?(prompt.method(:template))
1604
+ prompt.template(args, server_context: server_context)
1081
1605
  else
1082
- prompt.template(args).to_h
1606
+ prompt.template(args)
1083
1607
  end
1608
+
1609
+ raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
1084
1610
  end
1085
1611
 
1086
1612
  def server_context_with_meta(request)