mcp 0.25.0 → 1.6.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 +355 -66
- data/lib/mcp/client/mcp_param_headers.rb +242 -0
- data/lib/mcp/client/modern_envelope.rb +32 -0
- data/lib/mcp/client/oauth/authorization_request.rb +27 -0
- data/lib/mcp/client/oauth/bounded_body.rb +67 -0
- data/lib/mcp/client/oauth/client_credentials_provider.rb +20 -6
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +21 -1
- data/lib/mcp/client/oauth/discovery.rb +131 -14
- data/lib/mcp/client/oauth/flow.rb +586 -108
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
- data/lib/mcp/client/oauth/provider.rb +16 -1
- data/lib/mcp/client/oauth/storage_backed_provider.rb +49 -5
- data/lib/mcp/client/oauth.rb +2 -0
- data/lib/mcp/client/stdio.rb +271 -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/icon.rb +35 -2
- 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/tool.rb +1 -1
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +14 -3
|
@@ -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.build_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
|
|
@@ -48,6 +48,13 @@ module MCP
|
|
|
48
48
|
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
|
|
49
49
|
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
|
50
50
|
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
51
|
+
# - `token_request_params` - Hash of String keys and values added to every token request this provider makes
|
|
52
|
+
# (the authorization code exchange and refresh), for parameters the authorization server requires beyond
|
|
53
|
+
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
|
|
54
|
+
# raises `Flow::InvalidTokenRequestParamsError`.
|
|
55
|
+
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
|
56
|
+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
|
|
57
|
+
# its own requests; see `StorageBackedProvider#http_client_customizer`.
|
|
51
58
|
class Provider
|
|
52
59
|
include StorageBackedProvider
|
|
53
60
|
|
|
@@ -86,7 +93,10 @@ module MCP
|
|
|
86
93
|
callback_handler:,
|
|
87
94
|
scope: nil,
|
|
88
95
|
storage: nil,
|
|
89
|
-
client_id_metadata_document_url: nil
|
|
96
|
+
client_id_metadata_document_url: nil,
|
|
97
|
+
authorization_request_validator: nil,
|
|
98
|
+
token_request_params: nil,
|
|
99
|
+
http_client_customizer: nil
|
|
90
100
|
)
|
|
91
101
|
unless Discovery.secure_url?(redirect_uri)
|
|
92
102
|
raise InsecureRedirectURIError,
|
|
@@ -108,6 +118,8 @@ module MCP
|
|
|
108
118
|
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
|
109
119
|
end
|
|
110
120
|
|
|
121
|
+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
122
|
+
|
|
111
123
|
@client_metadata = client_metadata
|
|
112
124
|
@redirect_uri = redirect_uri
|
|
113
125
|
@redirect_handler = redirect_handler
|
|
@@ -115,6 +127,9 @@ module MCP
|
|
|
115
127
|
@scope = scope
|
|
116
128
|
@storage = storage || InMemoryStorage.new
|
|
117
129
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
130
|
+
@authorization_request_validator = authorization_request_validator
|
|
131
|
+
@token_request_params = frozen_token_request_params(token_request_params)
|
|
132
|
+
@http_client_customizer = http_client_customizer
|
|
118
133
|
end
|
|
119
134
|
|
|
120
135
|
# Identifies the OAuth flow this provider drives.
|
|
@@ -4,16 +4,37 @@ module MCP
|
|
|
4
4
|
class Client
|
|
5
5
|
module OAuth
|
|
6
6
|
# Shared token/credential persistence for the OAuth provider classes
|
|
7
|
-
# (`Provider` for the authorization-code flow
|
|
8
|
-
# for the client_credentials flow
|
|
9
|
-
# but
|
|
10
|
-
# the token response and the client information. This module supplies
|
|
11
|
-
# so the `Flow` orchestrator can treat any provider uniformly.
|
|
7
|
+
# (`Provider` for the authorization-code flow, `ClientCredentialsProvider`
|
|
8
|
+
# for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow).
|
|
9
|
+
# The grants differ in how they authenticate, but all read and write the same two pieces of state
|
|
10
|
+
# through a `storage` object: the token response and the client information. This module supplies
|
|
11
|
+
# that delegation so the `Flow` orchestrator can treat any provider uniformly.
|
|
12
12
|
#
|
|
13
13
|
# Including classes must set `@storage` to an object responding to `tokens`,
|
|
14
14
|
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
|
15
15
|
# (see `InMemoryStorage`).
|
|
16
16
|
module StorageBackedProvider
|
|
17
|
+
# Optional `->(request) { true | false }` hook, called with an `AuthorizationRequest`.
|
|
18
|
+
# An MCP server names its own authorization server and the scopes to ask for, so this is where
|
|
19
|
+
# an embedding application sees both before the request is made and can decline to proceed.
|
|
20
|
+
# `nil` (the default) authorizes whatever the server asked for, which is the behavior every
|
|
21
|
+
# MCP SDK has today.
|
|
22
|
+
attr_reader :authorization_request_validator
|
|
23
|
+
|
|
24
|
+
# Optional Hash of String keys and values added to every token request the provider makes, for parameters
|
|
25
|
+
# the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them;
|
|
26
|
+
# Auth0's `audience` is the usual one). `nil` (the default) adds nothing.
|
|
27
|
+
# Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value
|
|
28
|
+
# with `Flow::InvalidTokenRequestParamsError`.
|
|
29
|
+
attr_reader :token_request_params
|
|
30
|
+
|
|
31
|
+
# Optional callable invoked with the `Faraday::Connection` the flow builds for its own requests,
|
|
32
|
+
# after the SDK's defaults and before its origin guard (see `Flow.build_http_client`), so an application can
|
|
33
|
+
# add middleware to discovery, registration, and token requests or swap their adapter. `nil` (the default)
|
|
34
|
+
# keeps the default connection. The transport's own connection and customizer block never serve these requests:
|
|
35
|
+
# they are bound to the MCP server URL and carry headers meant for that server.
|
|
36
|
+
attr_reader :http_client_customizer
|
|
37
|
+
|
|
17
38
|
def access_token
|
|
18
39
|
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
|
19
40
|
end
|
|
@@ -37,6 +58,29 @@ module MCP
|
|
|
37
58
|
def clear_tokens!
|
|
38
59
|
@storage.save_tokens(nil)
|
|
39
60
|
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Constructors call this before writing to `storage`, so a rejected provider leaves it untouched.
|
|
65
|
+
# The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key,
|
|
66
|
+
# or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly
|
|
67
|
+
# because `Hash` copies and freezes only keys whose class is exactly `String`.
|
|
68
|
+
def frozen_token_request_params(params)
|
|
69
|
+
return if params.nil?
|
|
70
|
+
|
|
71
|
+
problem = Flow.token_request_params_problem(params)
|
|
72
|
+
raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem
|
|
73
|
+
|
|
74
|
+
params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns `customizer` when it is `nil` or callable and raises otherwise, so a connection object passed
|
|
78
|
+
# in place of a callable fails at construction rather than at the first `401`.
|
|
79
|
+
def validated_http_client_customizer(customizer)
|
|
80
|
+
return customizer if customizer.nil? || customizer.respond_to?(:call)
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "http_client_customizer must respond to call (got #{customizer.class})."
|
|
83
|
+
end
|
|
40
84
|
end
|
|
41
85
|
end
|
|
42
86
|
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
101
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
write_message(init_request)
|
|
96
|
-
response = read_response(init_request)
|
|
97
|
-
|
|
98
|
-
if response.key?("error")
|
|
99
|
-
error = response["error"]
|
|
100
|
-
raise RequestHandlerError.new(
|
|
101
|
-
"Server initialization failed: #{error["message"]}",
|
|
102
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
103
|
-
error_type: :internal_error,
|
|
104
|
-
)
|
|
105
|
-
end
|
|
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)
|
|
@@ -259,6 +436,18 @@ module MCP
|
|
|
259
436
|
|
|
260
437
|
parsed = JSON.parse(line.strip)
|
|
261
438
|
|
|
439
|
+
# A frame carrying `method` is a request or notification, never the awaited response,
|
|
440
|
+
# whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id
|
|
441
|
+
# may legitimately collide with the awaited one. Pings are answered;
|
|
442
|
+
# other server-to-client requests over stdio stay unsupported as documented,
|
|
443
|
+
# and notifications carry no id.
|
|
444
|
+
if parsed.is_a?(Hash) && parsed.key?("method")
|
|
445
|
+
# A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
|
|
446
|
+
# schema validation, so a ping carrying one is skipped rather than echoed back.
|
|
447
|
+
answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"])
|
|
448
|
+
next
|
|
449
|
+
end
|
|
450
|
+
|
|
262
451
|
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
|
|
263
452
|
# the same way as a frame without an id.
|
|
264
453
|
next unless parsed.is_a?(Hash) && parsed.key?("id")
|
|
@@ -274,6 +463,22 @@ module MCP
|
|
|
274
463
|
)
|
|
275
464
|
end
|
|
276
465
|
|
|
466
|
+
# The ping receiver "MUST respond promptly with an empty response". Answered inline from
|
|
467
|
+
# the read loop, so a keepalive cannot stall unanswered while a response is awaited;
|
|
468
|
+
# between requests nothing reads stdout, and a queued ping is answered on the next read.
|
|
469
|
+
def answer_ping(parsed)
|
|
470
|
+
@write_mutex.synchronize do
|
|
471
|
+
write_message({ jsonrpc: JsonRpcHandler::Version::V2_0, id: parsed["id"], result: {} })
|
|
472
|
+
end
|
|
473
|
+
rescue RequestHandlerError
|
|
474
|
+
# Best effort: a pong cannot be delivered over a broken stdin, and the failure must not
|
|
475
|
+
# surface as an error of the unrelated request whose response the loop is reading.
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def json_rpc_id?(id)
|
|
479
|
+
id.is_a?(String) || id.is_a?(Numeric)
|
|
480
|
+
end
|
|
481
|
+
|
|
277
482
|
def ensure_running!
|
|
278
483
|
return if @wait_thread.alive?
|
|
279
484
|
|
data/lib/mcp/client/tool.rb
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
module MCP
|
|
4
4
|
class Client
|
|
5
5
|
class Tool
|
|
6
|
-
attr_reader :name, :description, :input_schema, :output_schema
|
|
6
|
+
attr_reader :name, :description, :input_schema, :output_schema, :annotations
|
|
7
7
|
|
|
8
|
-
def initialize(name:, description:, input_schema:, output_schema: nil)
|
|
8
|
+
def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil)
|
|
9
9
|
@name = name
|
|
10
10
|
@description = description
|
|
11
11
|
@input_schema = input_schema
|
|
12
12
|
@output_schema = output_schema
|
|
13
|
+
@annotations = annotations
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
end
|