mcp 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +428 -9
- data/lib/json_rpc_handler.rb +3 -2
- data/lib/mcp/client/http.rb +338 -63
- data/lib/mcp/client/mcp_param_headers.rb +242 -0
- data/lib/mcp/client/modern_envelope.rb +32 -0
- data/lib/mcp/client/oauth/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +95 -7
- data/lib/mcp/client/stdio.rb +243 -66
- data/lib/mcp/client/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 +710 -20
- data/lib/mcp/server.rb +541 -43
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +77 -15
- 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 +11 -2
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)
|
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
|