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
|
@@ -33,9 +33,15 @@ module MCP
|
|
|
33
33
|
def run!(server_url:, resource_metadata_url: nil, scope: nil)
|
|
34
34
|
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
|
|
35
35
|
# first thing we contact in the OAuth flow, so it has to clear the same
|
|
36
|
-
# Communication Security bar as the OAuth endpoints downstream
|
|
36
|
+
# Communication Security bar as the OAuth endpoints downstream, and it has to
|
|
37
|
+
# point back at the server that issued the challenge.
|
|
37
38
|
if resource_metadata_url
|
|
38
39
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
40
|
+
ensure_same_origin!(
|
|
41
|
+
resource_metadata_url,
|
|
42
|
+
label: "WWW-Authenticate resource_metadata URL",
|
|
43
|
+
server_url: server_url,
|
|
44
|
+
)
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
prm, authorization_server = locate_authorization_server(
|
|
@@ -49,7 +55,11 @@ module MCP
|
|
|
49
55
|
# being redirected to credentials minted for a different audience.
|
|
50
56
|
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
51
57
|
|
|
52
|
-
as_metadata = authorization_server_metadata(
|
|
58
|
+
as_metadata = authorization_server_metadata(
|
|
59
|
+
authorization_server: authorization_server,
|
|
60
|
+
legacy: prm.nil?,
|
|
61
|
+
server_url: server_url,
|
|
62
|
+
)
|
|
53
63
|
|
|
54
64
|
case provider_authorization_flow
|
|
55
65
|
when :client_credentials
|
|
@@ -212,6 +222,11 @@ module MCP
|
|
|
212
222
|
|
|
213
223
|
if resource_metadata_url
|
|
214
224
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
225
|
+
ensure_same_origin!(
|
|
226
|
+
resource_metadata_url,
|
|
227
|
+
label: "WWW-Authenticate resource_metadata URL",
|
|
228
|
+
server_url: server_url,
|
|
229
|
+
)
|
|
215
230
|
end
|
|
216
231
|
|
|
217
232
|
prm, authorization_server = locate_authorization_server(
|
|
@@ -221,7 +236,11 @@ module MCP
|
|
|
221
236
|
|
|
222
237
|
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
223
238
|
|
|
224
|
-
as_metadata = authorization_server_metadata(
|
|
239
|
+
as_metadata = authorization_server_metadata(
|
|
240
|
+
authorization_server: authorization_server,
|
|
241
|
+
legacy: prm.nil?,
|
|
242
|
+
server_url: server_url,
|
|
243
|
+
)
|
|
225
244
|
|
|
226
245
|
client_info = if have_stored_client_info
|
|
227
246
|
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
@@ -296,6 +315,11 @@ module MCP
|
|
|
296
315
|
if prm
|
|
297
316
|
authorization_server = first_authorization_server(prm)
|
|
298
317
|
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
318
|
+
ensure_routable_destination!(
|
|
319
|
+
authorization_server,
|
|
320
|
+
label: "PRM `authorization_servers` entry",
|
|
321
|
+
server_url: server_url,
|
|
322
|
+
)
|
|
299
323
|
[prm, authorization_server]
|
|
300
324
|
else
|
|
301
325
|
authorization_base = server_origin!(server_url)
|
|
@@ -311,7 +335,7 @@ module MCP
|
|
|
311
335
|
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
|
|
312
336
|
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
|
|
313
337
|
# When even the metadata document is absent, the legacy spec's default endpoints are used.
|
|
314
|
-
def authorization_server_metadata(authorization_server:, legacy:)
|
|
338
|
+
def authorization_server_metadata(authorization_server:, legacy:, server_url:)
|
|
315
339
|
metadata = if legacy
|
|
316
340
|
begin
|
|
317
341
|
fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
@@ -324,7 +348,7 @@ module MCP
|
|
|
324
348
|
end
|
|
325
349
|
end
|
|
326
350
|
|
|
327
|
-
ensure_secure_endpoints!(metadata)
|
|
351
|
+
ensure_secure_endpoints!(metadata, server_url: server_url)
|
|
328
352
|
metadata
|
|
329
353
|
end
|
|
330
354
|
|
|
@@ -445,13 +469,72 @@ module MCP
|
|
|
445
469
|
"#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
|
|
446
470
|
end
|
|
447
471
|
|
|
448
|
-
|
|
472
|
+
# Requires a URL the *server* chose to sit on the origin the *caller* chose.
|
|
473
|
+
#
|
|
474
|
+
# Protected Resource Metadata describes the MCP server itself, so on a real deployment it is
|
|
475
|
+
# published on that server's own origin. Without this check a `WWW-Authenticate` challenge
|
|
476
|
+
# can aim the first request of the flow at any host the client can route to: the URL arrives
|
|
477
|
+
# from the network, it is fetched before the user approves anything, and the `resource` check that
|
|
478
|
+
# runs afterwards cannot un-send the request.
|
|
479
|
+
#
|
|
480
|
+
# RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than
|
|
481
|
+
# the specification. It is enforced unconditionally because no known deployment publishes its PRM
|
|
482
|
+
# anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see
|
|
483
|
+
# internal hosts that are named rather than addressed.
|
|
484
|
+
# https://www.rfc-editor.org/rfc/rfc9728#section-7.7
|
|
485
|
+
def ensure_same_origin!(url, label:, server_url:)
|
|
486
|
+
return if Discovery.same_origin?(url, server_url)
|
|
487
|
+
|
|
488
|
+
raise AuthorizationError,
|
|
489
|
+
"#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \
|
|
490
|
+
"#{sanitized_url(server_url).inspect}; refusing to fetch it."
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address,
|
|
494
|
+
# which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take.
|
|
495
|
+
#
|
|
496
|
+
# The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address,
|
|
497
|
+
# the whole flow is already inside that network and the authorization server legitimately lives there too.
|
|
498
|
+
# That covers `http://localhost` development, the conformance harness (which runs the MCP server and
|
|
499
|
+
# the authorization server on two loopback ports), and deployments that never leave a corporate network.
|
|
500
|
+
# Only a server reachable on the public internet is barred from steering the client inward.
|
|
501
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
502
|
+
def ensure_routable_destination!(url, label:, server_url:)
|
|
503
|
+
return unless private_network_url?(url)
|
|
504
|
+
return if private_network_url?(server_url)
|
|
505
|
+
|
|
506
|
+
raise AuthorizationError,
|
|
507
|
+
"#{label} #{sanitized_url(url).inspect} points into a private network range, " \
|
|
508
|
+
"which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it."
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def ensure_secure_endpoints!(as_metadata, server_url:)
|
|
449
512
|
["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
|
|
450
513
|
endpoint = as_metadata[key]
|
|
451
|
-
|
|
514
|
+
next unless endpoint
|
|
515
|
+
|
|
516
|
+
ensure_secure_url!(endpoint, label: "Authorization server #{key}")
|
|
517
|
+
ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url)
|
|
452
518
|
end
|
|
453
519
|
end
|
|
454
520
|
|
|
521
|
+
# `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse,
|
|
522
|
+
# so the rescue here is a backstop rather than a leniency.
|
|
523
|
+
def private_network_url?(url)
|
|
524
|
+
Discovery.private_network_host?(URI.parse(url.to_s).host)
|
|
525
|
+
rescue URI::InvalidURIError
|
|
526
|
+
false
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes
|
|
530
|
+
# when it reports a URL: these values come off the network and can carry credentials that would otherwise land in
|
|
531
|
+
# every log destination the error passes through.
|
|
532
|
+
def sanitized_url(url)
|
|
533
|
+
Discovery.canonicalize_origin_and_path(url)
|
|
534
|
+
rescue URI::Error
|
|
535
|
+
url.to_s
|
|
536
|
+
end
|
|
537
|
+
|
|
455
538
|
# Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
|
|
456
539
|
# identical (literal byte-for-byte equality, no normalization) to
|
|
457
540
|
# the issuer URL the client used to discover that document. This guards
|
|
@@ -938,32 +1021,71 @@ module MCP
|
|
|
938
1021
|
end
|
|
939
1022
|
|
|
940
1023
|
def http_get(url)
|
|
941
|
-
|
|
1024
|
+
bounded_request do |on_data|
|
|
1025
|
+
http_client.get(url) do |req|
|
|
1026
|
+
req.options.on_data = on_data
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
942
1029
|
end
|
|
943
1030
|
|
|
944
1031
|
def http_post_json(url, body)
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1032
|
+
bounded_request do |on_data|
|
|
1033
|
+
http_client.post(url) do |req|
|
|
1034
|
+
req.headers["Content-Type"] = "application/json"
|
|
1035
|
+
req.headers["Accept"] = "application/json"
|
|
1036
|
+
req.options.on_data = on_data
|
|
1037
|
+
req.body = JSON.generate(body)
|
|
1038
|
+
end
|
|
949
1039
|
end
|
|
950
1040
|
end
|
|
951
1041
|
|
|
952
1042
|
def http_post_form(url, form, headers: {})
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1043
|
+
bounded_request do |on_data|
|
|
1044
|
+
http_client.post(url) do |req|
|
|
1045
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
1046
|
+
req.headers["Accept"] = "application/json"
|
|
1047
|
+
|
|
1048
|
+
headers.each do |key, value|
|
|
1049
|
+
req.headers[key] = value
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
req.options.on_data = on_data
|
|
1053
|
+
req.body = URI.encode_www_form(form)
|
|
1054
|
+
end
|
|
958
1055
|
end
|
|
959
1056
|
end
|
|
960
1057
|
|
|
1058
|
+
# Issues a request with the response body bounded as it arrives, and returns the status paired with
|
|
1059
|
+
# that body. An over-cap response is refused rather than truncated: a partial discovery or token document
|
|
1060
|
+
# cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either,
|
|
1061
|
+
# since the same server would serve the same body.
|
|
1062
|
+
def bounded_request
|
|
1063
|
+
bounded = BoundedBody.new
|
|
1064
|
+
|
|
1065
|
+
response = yield(bounded.on_data)
|
|
1066
|
+
|
|
1067
|
+
bounded.response_for(response)
|
|
1068
|
+
rescue BoundedBody::TooLargeError => e
|
|
1069
|
+
raise AuthorizationError, "#{e.message}."
|
|
1070
|
+
end
|
|
1071
|
+
|
|
961
1072
|
def http_client
|
|
962
1073
|
@http_client ||= @http_client_factory.call
|
|
963
1074
|
end
|
|
964
1075
|
|
|
1076
|
+
# Deliberately built without redirect-following middleware. Every destination check in
|
|
1077
|
+
# this class runs against the URL as written, before the request goes out, so a connection
|
|
1078
|
+
# that transparently followed a `3xx` would let a server reach a host the checks just refused.
|
|
1079
|
+
# A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
|
|
1080
|
+
# and the guards above only cover the first hop.
|
|
1081
|
+
#
|
|
1082
|
+
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
|
|
1083
|
+
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
|
|
1084
|
+
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
|
|
1085
|
+
# after the check.
|
|
965
1086
|
def default_http_client
|
|
966
1087
|
require "faraday"
|
|
1088
|
+
|
|
967
1089
|
Faraday.new do |faraday|
|
|
968
1090
|
faraday.headers["Accept"] = "application/json"
|
|
969
1091
|
end
|
|
@@ -34,10 +34,13 @@ module MCP
|
|
|
34
34
|
def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
|
|
35
35
|
http_client ||= default_http_client
|
|
36
36
|
|
|
37
|
+
bounded = BoundedBody.new
|
|
38
|
+
|
|
37
39
|
response = begin
|
|
38
|
-
http_client.post(token_endpoint) do |req|
|
|
40
|
+
raw_response = http_client.post(token_endpoint) do |req|
|
|
39
41
|
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
40
42
|
req.headers["Accept"] = "application/json"
|
|
43
|
+
req.options.on_data = bounded.on_data
|
|
41
44
|
req.body = URI.encode_www_form(
|
|
42
45
|
"grant_type" => GRANT_TYPE,
|
|
43
46
|
"subject_token" => id_token,
|
|
@@ -48,6 +51,10 @@ module MCP
|
|
|
48
51
|
"client_id" => client_id,
|
|
49
52
|
)
|
|
50
53
|
end
|
|
54
|
+
|
|
55
|
+
bounded.response_for(raw_response)
|
|
56
|
+
rescue BoundedBody::TooLargeError => e
|
|
57
|
+
raise ExchangeError, "#{e.message}."
|
|
51
58
|
rescue Faraday::Error => e
|
|
52
59
|
raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
|
|
53
60
|
end
|
|
@@ -88,8 +95,12 @@ module MCP
|
|
|
88
95
|
assertion
|
|
89
96
|
end
|
|
90
97
|
|
|
98
|
+
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
|
|
99
|
+
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
|
|
100
|
+
# onto compressed bytes.
|
|
91
101
|
def default_http_client
|
|
92
102
|
require "faraday"
|
|
103
|
+
|
|
93
104
|
Faraday.new do |faraday|
|
|
94
105
|
faraday.headers["Accept"] = "application/json"
|
|
95
106
|
end
|
data/lib/mcp/client/oauth.rb
CHANGED
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -7,7 +7,9 @@ require "timeout"
|
|
|
7
7
|
require_relative "../../json_rpc_handler"
|
|
8
8
|
require_relative "../configuration"
|
|
9
9
|
require_relative "../methods"
|
|
10
|
+
require_relative "../protocol_deprecations"
|
|
10
11
|
require_relative "../version"
|
|
12
|
+
require_relative "modern_envelope"
|
|
11
13
|
|
|
12
14
|
module MCP
|
|
13
15
|
class Client
|
|
@@ -26,6 +28,12 @@ module MCP
|
|
|
26
28
|
# realistic JSON-RPC frame, including base64-embedded images.
|
|
27
29
|
MAX_LINE_BYTES = 4 * 1024 * 1024
|
|
28
30
|
|
|
31
|
+
# Seconds the `server/discover` probe may wait when no `read_timeout` was configured.
|
|
32
|
+
# A compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one
|
|
33
|
+
# that silently drops unknown methods would otherwise block `connect(mode: :auto)` forever.
|
|
34
|
+
# Matches the C# SDK's `DiscoverProbeTimeout` default.
|
|
35
|
+
DEFAULT_DISCOVER_PROBE_TIMEOUT = 5
|
|
36
|
+
|
|
29
37
|
attr_reader :command, :args, :env, :server_info
|
|
30
38
|
|
|
31
39
|
def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
|
|
@@ -49,6 +57,9 @@ module MCP
|
|
|
49
57
|
@started = false
|
|
50
58
|
@initialized = false
|
|
51
59
|
@server_info = nil
|
|
60
|
+
@modern_protocol_version = nil
|
|
61
|
+
@modern_client_info = nil
|
|
62
|
+
@modern_capabilities = nil
|
|
52
63
|
# Serializes writes to `@stdin` so a request line and a notification line emitted from
|
|
53
64
|
# different threads (e.g. cancellation) cannot interleave on the wire.
|
|
54
65
|
@write_mutex = Mutex.new
|
|
@@ -66,86 +77,57 @@ module MCP
|
|
|
66
77
|
#
|
|
67
78
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
68
79
|
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
|
69
|
-
# @param protocol_version [String, nil] Protocol version to offer.
|
|
70
|
-
# to `MCP::Configuration::
|
|
80
|
+
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
|
|
81
|
+
# Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
|
|
82
|
+
# raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
|
|
71
83
|
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
|
72
84
|
# @return [Hash] The server's `InitializeResult`.
|
|
73
85
|
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
|
|
74
86
|
# a malformed result, or an unsupported protocol version.
|
|
87
|
+
# @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs
|
|
88
|
+
# the handshake below, `:modern` skips it and probes `server/discover`,
|
|
89
|
+
# and `:auto` probes `server/discover` first, falling back to the legacy handshake
|
|
90
|
+
# when the server does not serve a mutually supported modern version.
|
|
75
91
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
76
|
-
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
77
|
-
return @server_info if
|
|
92
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
|
|
93
|
+
return @server_info if connected?
|
|
94
|
+
|
|
95
|
+
# Validated before `start` so a pure argument error never spawns the server process.
|
|
96
|
+
MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy
|
|
78
97
|
|
|
79
98
|
start unless @started
|
|
80
99
|
|
|
81
100
|
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
|
82
|
-
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
|
83
|
-
|
|
84
|
-
init_request = {
|
|
85
|
-
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
86
|
-
id: SecureRandom.uuid,
|
|
87
|
-
method: MCP::Methods::INITIALIZE,
|
|
88
|
-
params: {
|
|
89
|
-
protocolVersion: protocol_version,
|
|
90
|
-
capabilities: capabilities,
|
|
91
|
-
clientInfo: client_info,
|
|
92
|
-
},
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
write_message(init_request)
|
|
96
|
-
response = read_response(init_request)
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
unless response["result"].is_a?(Hash)
|
|
108
|
-
raise RequestHandlerError.new(
|
|
109
|
-
"Server initialization failed: missing result in response",
|
|
110
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
111
|
-
error_type: :internal_error,
|
|
112
|
-
)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
@server_info = response["result"]
|
|
116
|
-
|
|
117
|
-
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
118
|
-
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
119
|
-
# Per spec, if the client does not support the server's returned protocol version,
|
|
120
|
-
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before
|
|
121
|
-
# raising so a retry starts without a stale `server_info`.
|
|
122
|
-
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
|
123
|
-
@server_info = nil
|
|
124
|
-
raise RequestHandlerError.new(
|
|
125
|
-
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
126
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
127
|
-
error_type: :internal_error,
|
|
128
|
-
)
|
|
102
|
+
case mode
|
|
103
|
+
when :legacy
|
|
104
|
+
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
105
|
+
when :modern
|
|
106
|
+
connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
107
|
+
when :auto
|
|
108
|
+
connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
109
|
+
else
|
|
110
|
+
raise ArgumentError, "mode must be :legacy, :modern, or :auto"
|
|
129
111
|
end
|
|
112
|
+
end
|
|
130
113
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
write_message(notification)
|
|
137
|
-
rescue StandardError
|
|
138
|
-
@server_info = nil
|
|
139
|
-
raise
|
|
140
|
-
end
|
|
114
|
+
# Whether the transport operates in the stateless modern lifecycle (SEP-2575):
|
|
115
|
+
# no handshake was performed and every request carries the `_meta` envelope.
|
|
116
|
+
def modern?
|
|
117
|
+
!@modern_client_info.nil?
|
|
118
|
+
end
|
|
141
119
|
|
|
142
|
-
|
|
143
|
-
|
|
120
|
+
# The protocol version in use on this connection, independent of its era:
|
|
121
|
+
# negotiated by `initialize` (legacy) or adopted via `server/discover` (modern).
|
|
122
|
+
# Returns `nil` before `connect` and after `close`.
|
|
123
|
+
def protocol_version
|
|
124
|
+
@modern_protocol_version || (@server_info && @server_info["protocolVersion"])
|
|
144
125
|
end
|
|
145
126
|
|
|
146
|
-
# Returns true once `connect` has completed the handshake
|
|
127
|
+
# Returns true once `connect` has completed the handshake or adopted the modern lifecycle.
|
|
128
|
+
# Returns false before the handshake and after `close`.
|
|
147
129
|
def connected?
|
|
148
|
-
@initialized
|
|
130
|
+
@initialized || modern?
|
|
149
131
|
end
|
|
150
132
|
|
|
151
133
|
# Transports may yield once the request line has been written to `@stdin`.
|
|
@@ -153,7 +135,16 @@ module MCP
|
|
|
153
135
|
# write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
|
|
154
136
|
# so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
|
|
155
137
|
def send_request(request:)
|
|
156
|
-
|
|
138
|
+
method = request[:method] || request["method"]
|
|
139
|
+
if method == MCP::Methods::SERVER_DISCOVER
|
|
140
|
+
# `server/discover` (SEP-2575) is sessionless capability discovery that
|
|
141
|
+
# works before (or instead of) `connect`.
|
|
142
|
+
start unless @started
|
|
143
|
+
elsif !connected?
|
|
144
|
+
raise "MCP::Client#connect must be called before sending requests."
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
request = stamp_modern(request)
|
|
157
148
|
|
|
158
149
|
@write_mutex.synchronize do
|
|
159
150
|
write_message(request)
|
|
@@ -166,7 +157,7 @@ module MCP
|
|
|
166
157
|
# `notifications/cancelled` for an in-flight request.
|
|
167
158
|
def send_notification(notification:)
|
|
168
159
|
start unless @started
|
|
169
|
-
connect unless
|
|
160
|
+
connect unless connected?
|
|
170
161
|
|
|
171
162
|
@write_mutex.synchronize { write_message(notification) }
|
|
172
163
|
nil
|
|
@@ -228,10 +219,196 @@ module MCP
|
|
|
228
219
|
@started = false
|
|
229
220
|
@initialized = false
|
|
230
221
|
@server_info = nil
|
|
222
|
+
leave_modern_mode
|
|
231
223
|
end
|
|
232
224
|
|
|
233
225
|
private
|
|
234
226
|
|
|
227
|
+
def connect_legacy(client_info:, protocol_version:, capabilities:)
|
|
228
|
+
protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
|
|
229
|
+
|
|
230
|
+
init_request = {
|
|
231
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
232
|
+
id: SecureRandom.uuid,
|
|
233
|
+
method: MCP::Methods::INITIALIZE,
|
|
234
|
+
params: {
|
|
235
|
+
protocolVersion: protocol_version,
|
|
236
|
+
capabilities: capabilities,
|
|
237
|
+
clientInfo: client_info,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
write_message(init_request)
|
|
242
|
+
response = read_response(init_request)
|
|
243
|
+
|
|
244
|
+
if response.key?("error")
|
|
245
|
+
error = response["error"]
|
|
246
|
+
raise RequestHandlerError.new(
|
|
247
|
+
"Server initialization failed: #{error["message"]}",
|
|
248
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
249
|
+
error_type: :internal_error,
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
unless response["result"].is_a?(Hash)
|
|
254
|
+
raise RequestHandlerError.new(
|
|
255
|
+
"Server initialization failed: missing result in response",
|
|
256
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
257
|
+
error_type: :internal_error,
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
@server_info = response["result"]
|
|
262
|
+
|
|
263
|
+
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
264
|
+
unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
265
|
+
# Per spec, if the client does not support the server's returned protocol version,
|
|
266
|
+
# the client SHOULD disconnect. A modern version is rejected along with unknown ones:
|
|
267
|
+
# the handshake settles on a legacy version by definition, and the TypeScript and Python clients refuse
|
|
268
|
+
# a modern counter-offer the same way. Roll back the cached `InitializeResult` before raising
|
|
269
|
+
# so a retry starts without a stale `server_info`.
|
|
270
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
|
271
|
+
@server_info = nil
|
|
272
|
+
raise RequestHandlerError.new(
|
|
273
|
+
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
274
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
275
|
+
error_type: :internal_error,
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
begin
|
|
280
|
+
notification = {
|
|
281
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
282
|
+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
283
|
+
}
|
|
284
|
+
write_message(notification)
|
|
285
|
+
rescue StandardError
|
|
286
|
+
@server_info = nil
|
|
287
|
+
raise
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
@initialized = true
|
|
291
|
+
@server_info
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version.
|
|
295
|
+
# No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`.
|
|
296
|
+
def connect_modern(client_info:, protocol_version:, capabilities:)
|
|
297
|
+
version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION
|
|
298
|
+
unless MCP::Configuration.modern_protocol_version?(version)
|
|
299
|
+
raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
@modern_protocol_version = version
|
|
303
|
+
@modern_client_info = client_info
|
|
304
|
+
@modern_capabilities = capabilities || {}
|
|
305
|
+
|
|
306
|
+
begin
|
|
307
|
+
result = with_probe_read_timeout { probe_discover }
|
|
308
|
+
rescue StandardError
|
|
309
|
+
leave_modern_mode
|
|
310
|
+
raise
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
supported = result["supportedVersions"]
|
|
314
|
+
unless supported.is_a?(Array) && supported.include?(version)
|
|
315
|
+
leave_modern_mode
|
|
316
|
+
raise RequestHandlerError.new(
|
|
317
|
+
"Server discovery failed: no mutually supported modern protocol version " \
|
|
318
|
+
"(server supports #{supported.inspect})",
|
|
319
|
+
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
320
|
+
error_type: :internal_error,
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
|
|
325
|
+
# so the warning lives here now that the handshake cannot land on one.
|
|
326
|
+
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
|
|
327
|
+
|
|
328
|
+
@server_info = result
|
|
329
|
+
@server_info
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Probes `server/discover` and adopts the modern lifecycle when the server serves
|
|
333
|
+
# a mutually supported modern version; otherwise falls back to the legacy handshake.
|
|
334
|
+
# The fallback intentionally covers a successful discovery without a mutual modern
|
|
335
|
+
# version as well: during the 2026-07-28 rollout a server may answer discovery while
|
|
336
|
+
# only serving legacy versions.
|
|
337
|
+
def connect_auto(client_info:, protocol_version:, capabilities:)
|
|
338
|
+
modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
|
|
339
|
+
connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
|
|
340
|
+
rescue RequestHandlerError
|
|
341
|
+
# An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
|
|
342
|
+
# so the probe's failure is the real answer and propagates.
|
|
343
|
+
raise if modern_pin
|
|
344
|
+
|
|
345
|
+
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def leave_modern_mode
|
|
349
|
+
@modern_protocol_version = nil
|
|
350
|
+
@modern_client_info = nil
|
|
351
|
+
@modern_capabilities = nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Bounds the probe read when the caller configured no `read_timeout`, and only for the probe:
|
|
355
|
+
# regular requests keep the unbounded default so long-running tools are unaffected.
|
|
356
|
+
# The timeout surfaces as a `RequestHandlerError`, which `connect_auto` treats as legacy evidence
|
|
357
|
+
# and falls back on.
|
|
358
|
+
def with_probe_read_timeout
|
|
359
|
+
return yield if @read_timeout
|
|
360
|
+
|
|
361
|
+
@read_timeout = DEFAULT_DISCOVER_PROBE_TIMEOUT
|
|
362
|
+
begin
|
|
363
|
+
yield
|
|
364
|
+
ensure
|
|
365
|
+
@read_timeout = nil
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def probe_discover
|
|
370
|
+
request = {
|
|
371
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
372
|
+
id: SecureRandom.uuid,
|
|
373
|
+
method: MCP::Methods::SERVER_DISCOVER,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
@write_mutex.synchronize { write_message(stamp_modern(request)) }
|
|
377
|
+
response = read_response(request)
|
|
378
|
+
|
|
379
|
+
if response.key?("error")
|
|
380
|
+
error = response["error"]
|
|
381
|
+
raise RequestHandlerError.new(
|
|
382
|
+
"Server discovery failed: #{error["message"]}",
|
|
383
|
+
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
384
|
+
error_type: :internal_error,
|
|
385
|
+
)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
result = response["result"]
|
|
389
|
+
unless result.is_a?(Hash)
|
|
390
|
+
raise RequestHandlerError.new(
|
|
391
|
+
"Server discovery failed: missing result in response",
|
|
392
|
+
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
393
|
+
error_type: :internal_error,
|
|
394
|
+
)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
result
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Modern requests (never notifications, whose `_meta` has no envelope) carry the SEP-2575 triple.
|
|
401
|
+
def stamp_modern(request)
|
|
402
|
+
return request unless modern? && (request[:id] || request["id"])
|
|
403
|
+
|
|
404
|
+
ModernEnvelope.stamp(
|
|
405
|
+
request,
|
|
406
|
+
protocol_version: @modern_protocol_version,
|
|
407
|
+
client_info: @modern_client_info,
|
|
408
|
+
capabilities: @modern_capabilities,
|
|
409
|
+
)
|
|
410
|
+
end
|
|
411
|
+
|
|
235
412
|
def write_message(message)
|
|
236
413
|
ensure_running!
|
|
237
414
|
json = JSON.generate(message)
|