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.
- checksums.yaml +4 -4
- data/README.md +50 -2725
- 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/bounded_body.rb +67 -0
- data/lib/mcp/client/oauth/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +139 -17
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
- data/lib/mcp/client/oauth.rb +1 -0
- 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 +745 -24
- data/lib/mcp/server.rb +574 -48
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +104 -20
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +11 -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
|
|
@@ -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
|
|
399
|
-
#
|
|
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
|
|
408
|
-
#
|
|
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
|
-
|
|
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 =
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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`.
|
|
717
|
-
#
|
|
718
|
-
#
|
|
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::
|
|
723
|
-
capabilities:
|
|
724
|
-
serverInfo: server_info,
|
|
1172
|
+
supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
|
|
1173
|
+
capabilities: discover_capabilities,
|
|
725
1174
|
instructions: instructions,
|
|
726
|
-
|
|
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,
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
914
|
-
# which case the missing one is filled with
|
|
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 || "
|
|
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/
|
|
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)
|
|
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)
|
|
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)
|