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