mcp 0.25.0 → 1.4.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 -2548
- 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/tool.rb +3 -2
- data/lib/mcp/client.rb +364 -41
- data/lib/mcp/configuration.rb +84 -7
- data/lib/mcp/elicitation/enum_schema.rb +121 -0
- data/lib/mcp/elicitation.rb +10 -0
- data/lib/mcp/error_codes.rb +14 -8
- 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 +117 -0
- 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 +820 -24
- data/lib/mcp/server.rb +623 -54
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +104 -20
- data/lib/mcp/tool/response.rb +8 -2
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +12 -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
|
|
@@ -59,6 +65,43 @@ module MCP
|
|
|
59
65
|
end
|
|
60
66
|
end
|
|
61
67
|
|
|
68
|
+
# Raised when a request carries a protocol version the server does not support under the stateless lifecycle of
|
|
69
|
+
# MCP 2026-07-28 (SEP-2575). Maps to JSON-RPC error `-32022` with `data: { supported: [...], requested: "..." }`
|
|
70
|
+
# so the client can select a mutually supported version and retry.
|
|
71
|
+
#
|
|
72
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
73
|
+
class UnsupportedProtocolVersionError < RequestHandlerError
|
|
74
|
+
# No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash
|
|
75
|
+
# into keywords and fail with "unknown keywords".
|
|
76
|
+
def initialize(requested, request = nil)
|
|
77
|
+
super(
|
|
78
|
+
"Unsupported protocol version",
|
|
79
|
+
request,
|
|
80
|
+
error_type: :unsupported_protocol_version,
|
|
81
|
+
error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
|
|
82
|
+
error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" },
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Raised when processing a request requires a client capability the request did not declare in `_meta`
|
|
88
|
+
# (`io.modelcontextprotocol/clientCapabilities`). Per SEP-2575, servers MUST NOT rely on capabilities
|
|
89
|
+
# the client has not declared. Maps to JSON-RPC error `-32021` with `data: { requiredCapabilities: {...} }`
|
|
90
|
+
# listing the missing capabilities.
|
|
91
|
+
#
|
|
92
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
93
|
+
class MissingRequiredClientCapabilityError < RequestHandlerError
|
|
94
|
+
def initialize(required_capabilities, request = nil)
|
|
95
|
+
super(
|
|
96
|
+
"Missing required client capability",
|
|
97
|
+
request,
|
|
98
|
+
error_type: :missing_required_client_capability,
|
|
99
|
+
error_code: ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
|
|
100
|
+
error_data: { requiredCapabilities: required_capabilities },
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
62
105
|
# Raised when a requested resource URI does not exist. Per SEP-2164,
|
|
63
106
|
# resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602)
|
|
64
107
|
# with the requested URI in the error `data` member. Raise this from
|
|
@@ -85,6 +128,27 @@ module MCP
|
|
|
85
128
|
end
|
|
86
129
|
end
|
|
87
130
|
|
|
131
|
+
# Raised when a server-to-client request (sampling, elicitation, `roots/list`, `ping`) goes unanswered past its timeout.
|
|
132
|
+
# The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources,
|
|
133
|
+
# and to cancel the request on expiry; the transport sends `notifications/cancelled` before raising this.
|
|
134
|
+
# These requests exist only on connections speaking 2025-11-25 or earlier, since the modern lifecycle forbids them.
|
|
135
|
+
#
|
|
136
|
+
# Left uncaught in a handler, this answers the client's originating request with `-32001` rather than a generic
|
|
137
|
+
# internal error, so the peer that failed to answer can tell a timeout apart from a server fault. The code is not
|
|
138
|
+
# spec-allocated: it sits in the implementation-defined server range and is the value the Python SDK reports for
|
|
139
|
+
# this condition, so a client that already recognizes it there reads the same meaning here.
|
|
140
|
+
#
|
|
141
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
|
|
142
|
+
class RequestTimeoutError < RequestHandlerError
|
|
143
|
+
attr_reader :request_id, :timeout
|
|
144
|
+
|
|
145
|
+
def initialize(message, request_id:, timeout:)
|
|
146
|
+
super(message, nil, error_type: :request_timeout, error_code: -32001)
|
|
147
|
+
@request_id = request_id
|
|
148
|
+
@timeout = timeout
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
88
152
|
class MethodAlreadyDefinedError < StandardError
|
|
89
153
|
attr_reader :method_name
|
|
90
154
|
|
|
@@ -105,8 +169,20 @@ module MCP
|
|
|
105
169
|
# Allowed values for the SEP-2549 `cacheScope` cache hint.
|
|
106
170
|
CACHE_SCOPES = ["public", "private"].freeze
|
|
107
171
|
|
|
172
|
+
# Methods whose results are cacheable per SEP-2549.
|
|
173
|
+
# On the modern wire (2026-07-28) the `ttlMs`/`cacheScope` hints are REQUIRED on these results,
|
|
174
|
+
# so unset hints get the spec defaults there; on stable protocol versions emission stays opt-in
|
|
175
|
+
# via `apply_cache_metadata`.
|
|
176
|
+
CACHEABLE_RESULT_METHODS = [
|
|
177
|
+
Methods::TOOLS_LIST,
|
|
178
|
+
Methods::PROMPTS_LIST,
|
|
179
|
+
Methods::RESOURCES_LIST,
|
|
180
|
+
Methods::RESOURCES_TEMPLATES_LIST,
|
|
181
|
+
Methods::RESOURCES_READ,
|
|
182
|
+
].freeze
|
|
183
|
+
|
|
108
184
|
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
|
109
|
-
attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope
|
|
185
|
+
attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
|
|
110
186
|
|
|
111
187
|
def initialize(
|
|
112
188
|
description: nil,
|
|
@@ -126,6 +202,8 @@ module MCP
|
|
|
126
202
|
page_size: nil,
|
|
127
203
|
ttl_ms: nil,
|
|
128
204
|
cache_scope: nil,
|
|
205
|
+
request_state_security: nil,
|
|
206
|
+
input_required_legacy_shim: true,
|
|
129
207
|
transport: nil
|
|
130
208
|
)
|
|
131
209
|
@description = description
|
|
@@ -141,12 +219,21 @@ module MCP
|
|
|
141
219
|
@resources = resources
|
|
142
220
|
@resource_templates = resource_templates
|
|
143
221
|
@resource_index = index_resources_by_uri(resources)
|
|
222
|
+
@resources_list_handler = nil
|
|
144
223
|
@server_context = server_context
|
|
145
224
|
self.page_size = page_size
|
|
146
225
|
self.ttl_ms = ttl_ms
|
|
147
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
|
|
148
234
|
@configuration = MCP.configuration.merge(configuration)
|
|
149
235
|
@client = nil
|
|
236
|
+
@client_protocol_version = nil
|
|
150
237
|
|
|
151
238
|
validate!
|
|
152
239
|
|
|
@@ -340,6 +427,22 @@ module MCP
|
|
|
340
427
|
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
|
|
341
428
|
end
|
|
342
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
|
+
|
|
343
446
|
# Sets a custom handler for `resources/read` requests.
|
|
344
447
|
# The block receives the parsed request params and should return resource
|
|
345
448
|
# contents. The return value is set as the `contents` field of the response.
|
|
@@ -360,19 +463,25 @@ module MCP
|
|
|
360
463
|
end
|
|
361
464
|
|
|
362
465
|
# Sets a custom handler for `resources/subscribe` requests.
|
|
363
|
-
# The block receives the parsed request params. The
|
|
364
|
-
#
|
|
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`.
|
|
365
470
|
#
|
|
366
471
|
# @yield [params] The request params containing `:uri`.
|
|
472
|
+
# @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
|
|
367
473
|
def resources_subscribe_handler(&block)
|
|
368
474
|
@handlers[Methods::RESOURCES_SUBSCRIBE] = block
|
|
369
475
|
end
|
|
370
476
|
|
|
371
477
|
# Sets a custom handler for `resources/unsubscribe` requests.
|
|
372
|
-
# The block receives the parsed request params. The
|
|
373
|
-
#
|
|
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`.
|
|
374
482
|
#
|
|
375
483
|
# @yield [params] The request params containing `:uri`.
|
|
484
|
+
# @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
|
|
376
485
|
def resources_unsubscribe_handler(&block)
|
|
377
486
|
@handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
|
|
378
487
|
end
|
|
@@ -510,12 +619,47 @@ module MCP
|
|
|
510
619
|
return
|
|
511
620
|
end
|
|
512
621
|
|
|
513
|
-
|
|
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
|
|
514
642
|
|
|
515
643
|
# `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
|
|
516
644
|
# so do not track it in the in-flight registry.
|
|
517
|
-
cancellation =
|
|
518
|
-
|
|
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
|
|
519
663
|
end
|
|
520
664
|
|
|
521
665
|
->(params) {
|
|
@@ -525,25 +669,42 @@ module MCP
|
|
|
525
669
|
server_context: { request: request },
|
|
526
670
|
exception_already_reported: ->(e) { reported_exception.equal?(e) },
|
|
527
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
|
+
|
|
528
685
|
result = case method
|
|
529
686
|
when Methods::INITIALIZE
|
|
530
687
|
init(params, session: session)
|
|
531
688
|
when Methods::RESOURCES_READ
|
|
532
|
-
|
|
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)
|
|
533
693
|
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
|
534
694
|
validate_resource_subscription_params!(params)
|
|
535
|
-
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
536
|
-
|
|
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)
|
|
537
698
|
when Methods::TOOLS_CALL
|
|
538
|
-
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)
|
|
539
700
|
when Methods::PROMPTS_GET
|
|
540
|
-
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)
|
|
541
702
|
when Methods::COMPLETION_COMPLETE
|
|
542
|
-
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)
|
|
543
704
|
when Methods::LOGGING_SET_LEVEL
|
|
544
705
|
configure_logging_level(params, session: session)
|
|
545
706
|
else
|
|
546
|
-
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)
|
|
547
708
|
end
|
|
548
709
|
client = session&.client || @client
|
|
549
710
|
add_instrumentation_data(client: client) if client
|
|
@@ -553,6 +714,43 @@ module MCP
|
|
|
553
714
|
next JsonRpcHandler::NO_RESPONSE
|
|
554
715
|
end
|
|
555
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
|
+
|
|
556
754
|
result
|
|
557
755
|
rescue CancelledError => e
|
|
558
756
|
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
|
@@ -569,11 +767,267 @@ module MCP
|
|
|
569
767
|
reported_exception = wrapped
|
|
570
768
|
raise wrapped
|
|
571
769
|
ensure
|
|
572
|
-
|
|
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
|
|
573
774
|
end
|
|
574
775
|
}
|
|
575
776
|
end
|
|
576
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
|
+
|
|
577
1031
|
def handle_cancelled_notification(params, session: nil)
|
|
578
1032
|
return unless session
|
|
579
1033
|
return unless params.is_a?(Hash)
|
|
@@ -623,10 +1077,19 @@ module MCP
|
|
|
623
1077
|
end
|
|
624
1078
|
protocol_version = params[:protocolVersion]
|
|
625
1079
|
|
|
626
|
-
|
|
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)
|
|
627
1088
|
protocol_version
|
|
628
|
-
|
|
1089
|
+
elsif configuration.protocol_version?
|
|
629
1090
|
configuration.protocol_version
|
|
1091
|
+
else
|
|
1092
|
+
Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
|
|
630
1093
|
end
|
|
631
1094
|
|
|
632
1095
|
info = server_info.reject do |property|
|
|
@@ -640,7 +1103,11 @@ module MCP
|
|
|
640
1103
|
response_instructions = nil
|
|
641
1104
|
end
|
|
642
1105
|
|
|
643
|
-
session
|
|
1106
|
+
if session
|
|
1107
|
+
session.mark_initialized!(protocol_version: negotiated_version)
|
|
1108
|
+
else
|
|
1109
|
+
@client_protocol_version = negotiated_version
|
|
1110
|
+
end
|
|
644
1111
|
|
|
645
1112
|
{
|
|
646
1113
|
protocolVersion: negotiated_version,
|
|
@@ -656,6 +1123,18 @@ module MCP
|
|
|
656
1123
|
end
|
|
657
1124
|
end
|
|
658
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
|
+
|
|
659
1138
|
def validate_initialize_params!(params)
|
|
660
1139
|
unless params.is_a?(Hash)
|
|
661
1140
|
raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
|
|
@@ -675,20 +1154,47 @@ module MCP
|
|
|
675
1154
|
end
|
|
676
1155
|
end
|
|
677
1156
|
|
|
678
|
-
# Handles `server/discover` (MCP 2026-07-28
|
|
1157
|
+
# Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery.
|
|
679
1158
|
# Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
|
|
680
1159
|
# the session initialized, and responds regardless of capability declarations or initialization state,
|
|
681
|
-
# so clients can probe a server before (or instead of) `initialize`.
|
|
682
|
-
#
|
|
683
|
-
#
|
|
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.
|
|
684
1169
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
685
1170
|
def discover(_request)
|
|
686
1171
|
{
|
|
687
|
-
supportedVersions: Configuration::
|
|
688
|
-
capabilities:
|
|
689
|
-
serverInfo: server_info,
|
|
1172
|
+
supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
|
|
1173
|
+
capabilities: discover_capabilities,
|
|
690
1174
|
instructions: instructions,
|
|
691
|
-
|
|
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
|
|
692
1198
|
end
|
|
693
1199
|
|
|
694
1200
|
def configure_logging_level(request, session: nil)
|
|
@@ -713,7 +1219,7 @@ module MCP
|
|
|
713
1219
|
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
714
1220
|
end
|
|
715
1221
|
|
|
716
|
-
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)
|
|
717
1223
|
tool_name = request[:name]
|
|
718
1224
|
|
|
719
1225
|
tool = tools[tool_name]
|
|
@@ -745,18 +1251,38 @@ module MCP
|
|
|
745
1251
|
|
|
746
1252
|
progress_token = request.dig(:_meta, :progressToken)
|
|
747
1253
|
|
|
748
|
-
|
|
749
|
-
tool,
|
|
1254
|
+
response = call_tool_with_args(
|
|
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),
|
|
750
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
|
+
|
|
1270
|
+
result = response.to_h
|
|
751
1271
|
validate_tool_call_result!(tool, result)
|
|
752
|
-
serialize_structured_content_fallback(
|
|
1272
|
+
serialize_structured_content_fallback(
|
|
1273
|
+
result,
|
|
1274
|
+
content_provided: response.respond_to?(:content_provided?) && response.content_provided?,
|
|
1275
|
+
)
|
|
753
1276
|
rescue RequestHandlerError, CancelledError
|
|
754
1277
|
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
|
|
755
1278
|
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
|
|
756
1279
|
raise
|
|
757
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`.
|
|
758
1284
|
raise RequestHandlerError.new(
|
|
759
|
-
"Internal error calling tool #{tool_name}
|
|
1285
|
+
"Internal error calling tool #{tool_name}",
|
|
760
1286
|
request,
|
|
761
1287
|
error_type: :internal_error,
|
|
762
1288
|
original_error: e,
|
|
@@ -769,12 +1295,21 @@ module MCP
|
|
|
769
1295
|
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
770
1296
|
end
|
|
771
1297
|
|
|
772
|
-
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)
|
|
773
1299
|
prompt_name = request[:name]
|
|
774
1300
|
prompt = @prompts[prompt_name]
|
|
775
1301
|
unless prompt
|
|
776
1302
|
add_instrumentation_data(error: :prompt_not_found)
|
|
777
|
-
|
|
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
|
+
)
|
|
778
1313
|
end
|
|
779
1314
|
|
|
780
1315
|
add_instrumentation_data(prompt_name: prompt_name)
|
|
@@ -787,17 +1322,34 @@ module MCP
|
|
|
787
1322
|
session: session,
|
|
788
1323
|
related_request_id: related_request_id,
|
|
789
1324
|
cancellation: cancellation,
|
|
1325
|
+
envelope: envelope,
|
|
790
1326
|
)
|
|
791
1327
|
|
|
792
1328
|
call_prompt_template_with_args(prompt, prompt_args, server_context)
|
|
793
1329
|
end
|
|
794
1330
|
|
|
795
|
-
def list_resources(request)
|
|
796
|
-
|
|
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)
|
|
797
1339
|
|
|
798
1340
|
apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
799
1341
|
end
|
|
800
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
|
+
|
|
801
1353
|
# Default `resources/read` handler: routes to class-based resources and resource templates.
|
|
802
1354
|
# Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
|
|
803
1355
|
# unknown URIs keep the historical no-op `[]` response instead of raising.
|
|
@@ -871,18 +1423,19 @@ module MCP
|
|
|
871
1423
|
end
|
|
872
1424
|
|
|
873
1425
|
# Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added
|
|
874
|
-
# unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields,
|
|
875
|
-
# 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).
|
|
876
1429
|
# Values already in the result win, enabling per-result overrides.
|
|
877
1430
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
|
|
878
1431
|
def apply_cache_metadata(result)
|
|
879
1432
|
explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
|
|
880
1433
|
return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
|
|
881
1434
|
|
|
882
|
-
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "
|
|
1435
|
+
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result)
|
|
883
1436
|
end
|
|
884
1437
|
|
|
885
|
-
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)
|
|
886
1439
|
validate_completion_params!(params)
|
|
887
1440
|
|
|
888
1441
|
result = dispatch_optional_context_handler(
|
|
@@ -891,6 +1444,7 @@ module MCP
|
|
|
891
1444
|
session: session,
|
|
892
1445
|
related_request_id: related_request_id,
|
|
893
1446
|
cancellation: cancellation,
|
|
1447
|
+
envelope: envelope,
|
|
894
1448
|
)
|
|
895
1449
|
|
|
896
1450
|
normalize_completion_result(result)
|
|
@@ -899,13 +1453,14 @@ module MCP
|
|
|
899
1453
|
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
|
|
900
1454
|
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
|
|
901
1455
|
# `server_context.raise_if_cancelled!`.
|
|
902
|
-
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)
|
|
903
1457
|
dispatch_optional_context_handler(
|
|
904
1458
|
@handlers[Methods::RESOURCES_READ],
|
|
905
1459
|
request,
|
|
906
1460
|
session: session,
|
|
907
1461
|
related_request_id: related_request_id,
|
|
908
1462
|
cancellation: cancellation,
|
|
1463
|
+
envelope: envelope,
|
|
909
1464
|
)
|
|
910
1465
|
end
|
|
911
1466
|
|
|
@@ -913,7 +1468,7 @@ module MCP
|
|
|
913
1468
|
# `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
|
|
914
1469
|
# Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
|
|
915
1470
|
# keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
|
|
916
|
-
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
|
|
1471
|
+
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
|
917
1472
|
return handler.call(params) unless handler_declares_server_context?(handler)
|
|
918
1473
|
|
|
919
1474
|
server_context = build_server_context(
|
|
@@ -921,6 +1476,7 @@ module MCP
|
|
|
921
1476
|
session: session,
|
|
922
1477
|
related_request_id: related_request_id,
|
|
923
1478
|
cancellation: cancellation,
|
|
1479
|
+
envelope: envelope,
|
|
924
1480
|
)
|
|
925
1481
|
handler.call(params, server_context: server_context)
|
|
926
1482
|
end
|
|
@@ -945,16 +1501,20 @@ module MCP
|
|
|
945
1501
|
|
|
946
1502
|
# Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
|
|
947
1503
|
# (progress, cancellation, nested server-to-client requests).
|
|
948
|
-
def build_server_context(request:, session:, related_request_id:, cancellation:)
|
|
1504
|
+
def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
|
|
949
1505
|
meta_source = request.is_a?(Hash) ? request : {}
|
|
950
1506
|
progress_token = meta_source.dig(:_meta, :progressToken)
|
|
951
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)
|
|
952
1509
|
ServerContext.new(
|
|
953
1510
|
server_context_with_meta(meta_source),
|
|
954
1511
|
progress: progress,
|
|
955
1512
|
notification_target: session,
|
|
956
1513
|
related_request_id: related_request_id,
|
|
957
1514
|
cancellation: cancellation,
|
|
1515
|
+
envelope: envelope,
|
|
1516
|
+
input_responses: retry_fields[:input_responses],
|
|
1517
|
+
request_state: retry_fields[:request_state],
|
|
958
1518
|
)
|
|
959
1519
|
end
|
|
960
1520
|
|
|
@@ -986,14 +1546,18 @@ module MCP
|
|
|
986
1546
|
|
|
987
1547
|
# Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
|
|
988
1548
|
# Clients on older protocol versions may only read `content`,
|
|
989
|
-
# so when a tool returns non-object structured content without
|
|
990
|
-
#
|
|
991
|
-
def serialize_structured_content_fallback(result)
|
|
1549
|
+
# so when a tool returns non-object structured content without explicitly
|
|
1550
|
+
# providing `content`, mirror the value into serialized JSON text.
|
|
1551
|
+
def serialize_structured_content_fallback(result, content_provided: false)
|
|
992
1552
|
structured = result[:structuredContent]
|
|
993
1553
|
return result if structured.nil? || structured.is_a?(Hash)
|
|
1554
|
+
return result if content_provided
|
|
994
1555
|
return result unless result[:content].nil? || result[:content].empty?
|
|
995
1556
|
|
|
996
|
-
|
|
1557
|
+
serialized = JSON.generate(structured)
|
|
1558
|
+
return result if JSON.parse(serialized).is_a?(Hash)
|
|
1559
|
+
|
|
1560
|
+
result.merge(content: [{ type: "text", text: serialized }])
|
|
997
1561
|
end
|
|
998
1562
|
|
|
999
1563
|
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
|
|
@@ -1010,11 +1574,11 @@ module MCP
|
|
|
1010
1574
|
end
|
|
1011
1575
|
end
|
|
1012
1576
|
|
|
1013
|
-
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)
|
|
1014
1578
|
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
|
|
1015
1579
|
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
|
|
1016
1580
|
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
|
|
1017
|
-
# See docs/
|
|
1581
|
+
# See docs/server/tools.md ("Tool argument keys").
|
|
1018
1582
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
1019
1583
|
|
|
1020
1584
|
if accepts_server_context?(tool.method(:call))
|
|
@@ -1025,19 +1589,24 @@ module MCP
|
|
|
1025
1589
|
notification_target: session,
|
|
1026
1590
|
related_request_id: related_request_id,
|
|
1027
1591
|
cancellation: cancellation,
|
|
1592
|
+
envelope: envelope,
|
|
1593
|
+
input_responses: retry_fields&.fetch(:input_responses, nil),
|
|
1594
|
+
request_state: retry_fields&.fetch(:request_state, nil),
|
|
1028
1595
|
)
|
|
1029
|
-
tool.call(**args, server_context: server_context)
|
|
1596
|
+
tool.call(**args, server_context: server_context)
|
|
1030
1597
|
else
|
|
1031
|
-
tool.call(**args)
|
|
1598
|
+
tool.call(**args)
|
|
1032
1599
|
end
|
|
1033
1600
|
end
|
|
1034
1601
|
|
|
1035
1602
|
def call_prompt_template_with_args(prompt, args, server_context)
|
|
1036
|
-
if accepts_server_context?(prompt.method(:template))
|
|
1037
|
-
prompt.template(args, server_context: server_context)
|
|
1603
|
+
raw_result = if accepts_server_context?(prompt.method(:template))
|
|
1604
|
+
prompt.template(args, server_context: server_context)
|
|
1038
1605
|
else
|
|
1039
|
-
prompt.template(args)
|
|
1606
|
+
prompt.template(args)
|
|
1040
1607
|
end
|
|
1608
|
+
|
|
1609
|
+
raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
|
|
1041
1610
|
end
|
|
1042
1611
|
|
|
1043
1612
|
def server_context_with_meta(request)
|