mcp 0.13.0 → 0.15.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 +373 -6
- data/lib/json_rpc_handler.rb +6 -0
- data/lib/mcp/cancellation.rb +72 -0
- data/lib/mcp/cancelled_error.rb +13 -0
- data/lib/mcp/client/http.rb +230 -14
- data/lib/mcp/client/paginated_result.rb +13 -0
- data/lib/mcp/client/stdio.rb +100 -49
- data/lib/mcp/client.rb +235 -22
- data/lib/mcp/methods.rb +2 -4
- data/lib/mcp/server/pagination.rb +42 -0
- data/lib/mcp/server/transports/stdio_transport.rb +7 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +82 -7
- data/lib/mcp/server.rb +204 -33
- data/lib/mcp/server_context.rb +30 -1
- data/lib/mcp/server_session.rb +121 -20
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +8 -5
- data/lib/mcp/transports/stdio.rb +0 -15
data/lib/mcp/client/http.rb
CHANGED
|
@@ -1,25 +1,134 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "../../json_rpc_handler"
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../methods"
|
|
7
|
+
require_relative "../version"
|
|
8
|
+
|
|
3
9
|
module MCP
|
|
4
10
|
class Client
|
|
11
|
+
# TODO: HTTP GET for SSE streaming is not yet implemented.
|
|
12
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
|
|
13
|
+
# TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
|
|
14
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
|
|
5
15
|
class HTTP
|
|
6
16
|
ACCEPT_HEADER = "application/json, text/event-stream"
|
|
17
|
+
SESSION_ID_HEADER = "Mcp-Session-Id"
|
|
18
|
+
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
|
7
19
|
|
|
8
|
-
attr_reader :url
|
|
20
|
+
attr_reader :url, :session_id, :protocol_version, :server_info
|
|
9
21
|
|
|
10
22
|
def initialize(url:, headers: {}, &block)
|
|
11
23
|
@url = url
|
|
12
24
|
@headers = headers
|
|
13
25
|
@faraday_customizer = block
|
|
26
|
+
@session_id = nil
|
|
27
|
+
@protocol_version = nil
|
|
28
|
+
@server_info = nil
|
|
29
|
+
@connected = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
33
|
+
# followed by the required `notifications/initialized` notification. The
|
|
34
|
+
# server's `InitializeResult` (protocol version, capabilities, server
|
|
35
|
+
# info, instructions) is cached on the transport and returned.
|
|
36
|
+
#
|
|
37
|
+
# Idempotent: a second call returns the cached `InitializeResult` without
|
|
38
|
+
# contacting the server. After `close`, state is cleared and `connect`
|
|
39
|
+
# will handshake again.
|
|
40
|
+
#
|
|
41
|
+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
42
|
+
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
|
43
|
+
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
|
44
|
+
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
|
45
|
+
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
|
46
|
+
# @return [Hash] The server's `InitializeResult`.
|
|
47
|
+
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
|
|
48
|
+
# or a malformed result.
|
|
49
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
50
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
51
|
+
return @server_info if connected?
|
|
52
|
+
|
|
53
|
+
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
|
54
|
+
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
|
55
|
+
|
|
56
|
+
response = send_request(request: {
|
|
57
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
58
|
+
id: SecureRandom.uuid,
|
|
59
|
+
method: MCP::Methods::INITIALIZE,
|
|
60
|
+
params: {
|
|
61
|
+
protocolVersion: protocol_version,
|
|
62
|
+
capabilities: capabilities,
|
|
63
|
+
clientInfo: client_info,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if response.is_a?(Hash) && response.key?("error")
|
|
68
|
+
clear_session
|
|
69
|
+
error = response["error"]
|
|
70
|
+
raise RequestHandlerError.new(
|
|
71
|
+
"Server initialization failed: #{error["message"]}",
|
|
72
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
73
|
+
error_type: :internal_error,
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless response.is_a?(Hash) && response["result"].is_a?(Hash)
|
|
78
|
+
clear_session
|
|
79
|
+
raise RequestHandlerError.new(
|
|
80
|
+
"Server initialization failed: missing result in response",
|
|
81
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
82
|
+
error_type: :internal_error,
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@server_info = response["result"]
|
|
87
|
+
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
88
|
+
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
89
|
+
clear_session
|
|
90
|
+
raise RequestHandlerError.new(
|
|
91
|
+
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
92
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
93
|
+
error_type: :internal_error,
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
send_request(request: {
|
|
99
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
100
|
+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
101
|
+
})
|
|
102
|
+
rescue StandardError
|
|
103
|
+
clear_session
|
|
104
|
+
raise
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@connected = true
|
|
108
|
+
@server_info
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns true once `connect` has completed the full handshake
|
|
112
|
+
# (`initialize` response received and `notifications/initialized` sent).
|
|
113
|
+
# Returns false before the first handshake and after `close`.
|
|
114
|
+
def connected?
|
|
115
|
+
@connected
|
|
14
116
|
end
|
|
15
117
|
|
|
118
|
+
# Sends a JSON-RPC request and returns the parsed response body.
|
|
119
|
+
# After a successful `initialize` handshake, the session ID and protocol
|
|
120
|
+
# version returned by the server are captured and automatically included
|
|
121
|
+
# on subsequent requests.
|
|
16
122
|
def send_request(request:)
|
|
17
123
|
method = request[:method] || request["method"]
|
|
18
124
|
params = request[:params] || request["params"]
|
|
19
125
|
|
|
20
|
-
response = client.post("", request)
|
|
21
|
-
|
|
22
|
-
|
|
126
|
+
response = client.post("", request, session_headers)
|
|
127
|
+
body = parse_response_body(response, method, params)
|
|
128
|
+
|
|
129
|
+
capture_session_info(method, response, body)
|
|
130
|
+
|
|
131
|
+
body
|
|
23
132
|
rescue Faraday::BadRequestError => e
|
|
24
133
|
raise RequestHandlerError.new(
|
|
25
134
|
"The #{method} request is invalid",
|
|
@@ -42,12 +151,25 @@ module MCP
|
|
|
42
151
|
original_error: e,
|
|
43
152
|
)
|
|
44
153
|
rescue Faraday::ResourceNotFound => e
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
154
|
+
# Per spec, 404 is the session-expired signal only when the request
|
|
155
|
+
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
|
|
156
|
+
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
|
|
157
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
|
158
|
+
if @session_id
|
|
159
|
+
clear_session
|
|
160
|
+
raise SessionExpiredError.new(
|
|
161
|
+
"The #{method} request is not found",
|
|
162
|
+
{ method: method, params: params },
|
|
163
|
+
original_error: e,
|
|
164
|
+
)
|
|
165
|
+
else
|
|
166
|
+
raise RequestHandlerError.new(
|
|
167
|
+
"The #{method} request is not found",
|
|
168
|
+
{ method: method, params: params },
|
|
169
|
+
error_type: :not_found,
|
|
170
|
+
original_error: e,
|
|
171
|
+
)
|
|
172
|
+
end
|
|
51
173
|
rescue Faraday::UnprocessableEntityError => e
|
|
52
174
|
raise RequestHandlerError.new(
|
|
53
175
|
"The #{method} request is unprocessable",
|
|
@@ -64,6 +186,31 @@ module MCP
|
|
|
64
186
|
)
|
|
65
187
|
end
|
|
66
188
|
|
|
189
|
+
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
|
|
190
|
+
# with the current `Mcp-Session-Id` header, and clears locally tracked
|
|
191
|
+
# session state afterward. No-op when no session has been established.
|
|
192
|
+
#
|
|
193
|
+
# Per spec, the server MAY respond with HTTP 405 Method Not Allowed when
|
|
194
|
+
# it does not support client-initiated termination, and returns 404 for
|
|
195
|
+
# a session it has already terminated. Both mean the session is gone —
|
|
196
|
+
# the desired end state. Other errors surface to the caller; local
|
|
197
|
+
# session state is cleared either way.
|
|
198
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
|
199
|
+
def close
|
|
200
|
+
unless @session_id
|
|
201
|
+
clear_session
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
begin
|
|
206
|
+
client.delete("", nil, session_headers)
|
|
207
|
+
rescue Faraday::ClientError => e
|
|
208
|
+
raise unless [404, 405].include?(e.response&.dig(:status))
|
|
209
|
+
ensure
|
|
210
|
+
clear_session
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
67
214
|
private
|
|
68
215
|
|
|
69
216
|
attr_reader :headers
|
|
@@ -84,6 +231,33 @@ module MCP
|
|
|
84
231
|
end
|
|
85
232
|
end
|
|
86
233
|
|
|
234
|
+
# Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
|
|
235
|
+
# and `MCP-Protocol-Version` on all requests after `initialize`.
|
|
236
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
|
237
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
|
|
238
|
+
def session_headers
|
|
239
|
+
request_headers = {}
|
|
240
|
+
request_headers[SESSION_ID_HEADER] = @session_id if @session_id
|
|
241
|
+
request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
|
|
242
|
+
request_headers
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def capture_session_info(method, response, body)
|
|
246
|
+
return unless method.to_s == Methods::INITIALIZE
|
|
247
|
+
|
|
248
|
+
# Faraday normalizes header names to lowercase.
|
|
249
|
+
session_id = response.headers[SESSION_ID_HEADER.downcase]
|
|
250
|
+
@session_id ||= session_id unless session_id.to_s.empty?
|
|
251
|
+
@protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def clear_session
|
|
255
|
+
@session_id = nil
|
|
256
|
+
@protocol_version = nil
|
|
257
|
+
@server_info = nil
|
|
258
|
+
@connected = false
|
|
259
|
+
end
|
|
260
|
+
|
|
87
261
|
def require_faraday!
|
|
88
262
|
require "faraday"
|
|
89
263
|
rescue LoadError
|
|
@@ -92,14 +266,56 @@ module MCP
|
|
|
92
266
|
"See https://rubygems.org/gems/faraday for more details."
|
|
93
267
|
end
|
|
94
268
|
|
|
95
|
-
def
|
|
269
|
+
def require_event_stream_parser!
|
|
270
|
+
require "event_stream_parser"
|
|
271
|
+
rescue LoadError
|
|
272
|
+
raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
|
|
273
|
+
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
|
|
274
|
+
"See https://rubygems.org/gems/event_stream_parser for more details."
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def parse_response_body(response, method, params)
|
|
278
|
+
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
|
|
279
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
280
|
+
return if response.status == 202
|
|
281
|
+
|
|
96
282
|
content_type = response.headers["Content-Type"]
|
|
97
|
-
|
|
283
|
+
|
|
284
|
+
if content_type&.include?("text/event-stream")
|
|
285
|
+
parse_sse_response(response.body, method, params)
|
|
286
|
+
elsif content_type&.include?("application/json")
|
|
287
|
+
response.body
|
|
288
|
+
else
|
|
289
|
+
raise RequestHandlerError.new(
|
|
290
|
+
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
|
|
291
|
+
{ method: method, params: params },
|
|
292
|
+
error_type: :unsupported_media_type,
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def parse_sse_response(body, method, params)
|
|
298
|
+
require_event_stream_parser!
|
|
299
|
+
|
|
300
|
+
json_rpc_response = nil
|
|
301
|
+
parser = EventStreamParser::Parser.new
|
|
302
|
+
parser.feed(body.to_s) do |_type, data, _id|
|
|
303
|
+
next if data.empty?
|
|
304
|
+
|
|
305
|
+
begin
|
|
306
|
+
parsed = JSON.parse(data)
|
|
307
|
+
json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
|
|
308
|
+
rescue JSON::ParserError
|
|
309
|
+
next
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
return json_rpc_response if json_rpc_response
|
|
98
314
|
|
|
99
315
|
raise RequestHandlerError.new(
|
|
100
|
-
"
|
|
316
|
+
"No valid JSON-RPC response found in SSE stream",
|
|
101
317
|
{ method: method, params: params },
|
|
102
|
-
error_type: :
|
|
318
|
+
error_type: :parse_error,
|
|
103
319
|
)
|
|
104
320
|
end
|
|
105
321
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
# Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
|
|
6
|
+
# Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
|
|
7
|
+
# and an optional `meta` hash mirroring the MCP `_meta` response field.
|
|
8
|
+
ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
|
|
9
|
+
ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
|
|
10
|
+
ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
|
|
11
|
+
ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true)
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -19,7 +19,7 @@ module MCP
|
|
|
19
19
|
CLOSE_TIMEOUT = 2
|
|
20
20
|
STDERR_READ_SIZE = 4096
|
|
21
21
|
|
|
22
|
-
attr_reader :command, :args, :env
|
|
22
|
+
attr_reader :command, :args, :env, :server_info
|
|
23
23
|
|
|
24
24
|
def initialize(command:, args: [], env: nil, read_timeout: nil)
|
|
25
25
|
@command = command
|
|
@@ -33,11 +33,108 @@ module MCP
|
|
|
33
33
|
@stderr_thread = nil
|
|
34
34
|
@started = false
|
|
35
35
|
@initialized = false
|
|
36
|
+
@server_info = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
40
|
+
# followed by the required `notifications/initialized` notification. The
|
|
41
|
+
# server's `InitializeResult` (protocol version, capabilities, server
|
|
42
|
+
# info, instructions) is cached on the transport and returned.
|
|
43
|
+
#
|
|
44
|
+
# Idempotent: a second call returns the cached `InitializeResult` without
|
|
45
|
+
# contacting the server. After `close`, state is cleared and `connect`
|
|
46
|
+
# will handshake again. Spawns the subprocess via `start` if it has not
|
|
47
|
+
# been started yet.
|
|
48
|
+
#
|
|
49
|
+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
50
|
+
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
|
51
|
+
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
|
52
|
+
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
|
53
|
+
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
|
54
|
+
# @return [Hash] The server's `InitializeResult`.
|
|
55
|
+
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
|
|
56
|
+
# a malformed result, or an unsupported protocol version.
|
|
57
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
58
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
59
|
+
return @server_info if @initialized
|
|
60
|
+
|
|
61
|
+
start unless @started
|
|
62
|
+
|
|
63
|
+
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
|
64
|
+
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
|
65
|
+
|
|
66
|
+
init_request = {
|
|
67
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
68
|
+
id: SecureRandom.uuid,
|
|
69
|
+
method: MCP::Methods::INITIALIZE,
|
|
70
|
+
params: {
|
|
71
|
+
protocolVersion: protocol_version,
|
|
72
|
+
capabilities: capabilities,
|
|
73
|
+
clientInfo: client_info,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
write_message(init_request)
|
|
78
|
+
response = read_response(init_request)
|
|
79
|
+
|
|
80
|
+
if response.key?("error")
|
|
81
|
+
error = response["error"]
|
|
82
|
+
raise RequestHandlerError.new(
|
|
83
|
+
"Server initialization failed: #{error["message"]}",
|
|
84
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
85
|
+
error_type: :internal_error,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
unless response["result"].is_a?(Hash)
|
|
90
|
+
raise RequestHandlerError.new(
|
|
91
|
+
"Server initialization failed: missing result in response",
|
|
92
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
93
|
+
error_type: :internal_error,
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@server_info = response["result"]
|
|
98
|
+
|
|
99
|
+
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
100
|
+
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
101
|
+
# Per spec, if the client does not support the server's returned protocol version,
|
|
102
|
+
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before
|
|
103
|
+
# raising so a retry starts without a stale `server_info`.
|
|
104
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
|
105
|
+
@server_info = nil
|
|
106
|
+
raise RequestHandlerError.new(
|
|
107
|
+
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
108
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
109
|
+
error_type: :internal_error,
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
notification = {
|
|
115
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
116
|
+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
117
|
+
}
|
|
118
|
+
write_message(notification)
|
|
119
|
+
rescue StandardError
|
|
120
|
+
@server_info = nil
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@initialized = true
|
|
125
|
+
@server_info
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns true once `connect` (or the implicit handshake on the first
|
|
129
|
+
# `send_request`) has completed. Returns false before the handshake
|
|
130
|
+
# and after `close`.
|
|
131
|
+
def connected?
|
|
132
|
+
@initialized
|
|
36
133
|
end
|
|
37
134
|
|
|
38
135
|
def send_request(request:)
|
|
39
136
|
start unless @started
|
|
40
|
-
|
|
137
|
+
connect unless @initialized
|
|
41
138
|
|
|
42
139
|
write_message(request)
|
|
43
140
|
read_response(request)
|
|
@@ -98,57 +195,11 @@ module MCP
|
|
|
98
195
|
@stderr_thread.join(CLOSE_TIMEOUT)
|
|
99
196
|
@started = false
|
|
100
197
|
@initialized = false
|
|
198
|
+
@server_info = nil
|
|
101
199
|
end
|
|
102
200
|
|
|
103
201
|
private
|
|
104
202
|
|
|
105
|
-
# The client MUST send a protocol version it supports. This SHOULD be the latest version.
|
|
106
|
-
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
|
107
|
-
#
|
|
108
|
-
# Always sends `LATEST_STABLE_PROTOCOL_VERSION`, matching the Python and TypeScript SDKs:
|
|
109
|
-
# https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/session.py#L175
|
|
110
|
-
# https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/index.ts#L495
|
|
111
|
-
def initialize_session
|
|
112
|
-
init_request = {
|
|
113
|
-
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
114
|
-
id: SecureRandom.uuid,
|
|
115
|
-
method: MCP::Methods::INITIALIZE,
|
|
116
|
-
params: {
|
|
117
|
-
protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
|
118
|
-
capabilities: {},
|
|
119
|
-
clientInfo: { name: "mcp-ruby-client", version: MCP::VERSION },
|
|
120
|
-
},
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
write_message(init_request)
|
|
124
|
-
response = read_response(init_request)
|
|
125
|
-
|
|
126
|
-
if response.key?("error")
|
|
127
|
-
error = response["error"]
|
|
128
|
-
raise RequestHandlerError.new(
|
|
129
|
-
"Server initialization failed: #{error["message"]}",
|
|
130
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
131
|
-
error_type: :internal_error,
|
|
132
|
-
)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
unless response.key?("result")
|
|
136
|
-
raise RequestHandlerError.new(
|
|
137
|
-
"Server initialization failed: missing result in response",
|
|
138
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
139
|
-
error_type: :internal_error,
|
|
140
|
-
)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
notification = {
|
|
144
|
-
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
145
|
-
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
146
|
-
}
|
|
147
|
-
write_message(notification)
|
|
148
|
-
|
|
149
|
-
@initialized = true
|
|
150
|
-
end
|
|
151
|
-
|
|
152
203
|
def write_message(message)
|
|
153
204
|
ensure_running!
|
|
154
205
|
json = JSON.generate(message)
|