mcp 0.10.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 +1384 -691
- data/lib/json_rpc_handler.rb +23 -10
- data/lib/mcp/cancellation.rb +72 -0
- data/lib/mcp/cancelled_error.rb +13 -0
- data/lib/mcp/client/http.rb +234 -15
- data/lib/mcp/client/paginated_result.rb +13 -0
- data/lib/mcp/client/stdio.rb +100 -49
- data/lib/mcp/client.rb +293 -66
- data/lib/mcp/configuration.rb +38 -2
- data/lib/mcp/content.rb +16 -12
- data/lib/mcp/instrumentation.rb +23 -2
- data/lib/mcp/methods.rb +4 -5
- data/lib/mcp/progress.rb +3 -1
- data/lib/mcp/prompt/result.rb +4 -3
- data/lib/mcp/resource/contents.rb +8 -7
- data/lib/mcp/resource.rb +4 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/pagination.rb +42 -0
- data/lib/mcp/server/transports/stdio_transport.rb +42 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +383 -88
- data/lib/mcp/server.rb +361 -50
- data/lib/mcp/server_context.rb +81 -2
- data/lib/mcp/server_session.rb +170 -12
- data/lib/mcp/tool/response.rb +4 -3
- data/lib/mcp/tool/schema.rb +1 -14
- data/lib/mcp/transport.rb +14 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +11 -6
- data/lib/mcp/transports/stdio.rb +0 -15
data/lib/json_rpc_handler.rb
CHANGED
|
@@ -18,6 +18,11 @@ module JsonRpcHandler
|
|
|
18
18
|
|
|
19
19
|
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
|
|
20
20
|
|
|
21
|
+
# Sentinel return value from a handler. When a handler returns this,
|
|
22
|
+
# `process_request` emits no JSON-RPC response for the request,
|
|
23
|
+
# matching the notification-style semantics (id is ignored).
|
|
24
|
+
NO_RESPONSE = Object.new.freeze
|
|
25
|
+
|
|
21
26
|
extend self
|
|
22
27
|
|
|
23
28
|
def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
|
|
@@ -92,7 +97,7 @@ module JsonRpcHandler
|
|
|
92
97
|
end
|
|
93
98
|
|
|
94
99
|
begin
|
|
95
|
-
method = method_finder.call(method_name)
|
|
100
|
+
method = method_finder.call(method_name, id)
|
|
96
101
|
|
|
97
102
|
if method.nil?
|
|
98
103
|
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
|
|
@@ -103,6 +108,7 @@ module JsonRpcHandler
|
|
|
103
108
|
end
|
|
104
109
|
|
|
105
110
|
result = method.call(params)
|
|
111
|
+
return if result.equal?(NO_RESPONSE)
|
|
106
112
|
|
|
107
113
|
success_response(id: id, result: result)
|
|
108
114
|
rescue MCP::Server::RequestHandlerError => e
|
|
@@ -117,20 +123,27 @@ module JsonRpcHandler
|
|
|
117
123
|
end
|
|
118
124
|
|
|
119
125
|
def handle_request_error(error, id, id_validation_pattern)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
if error.respond_to?(:error_code) && error.error_code
|
|
127
|
+
code = error.error_code
|
|
128
|
+
message = error.message
|
|
129
|
+
else
|
|
130
|
+
error_type = error.respond_to?(:error_type) ? error.error_type : nil
|
|
131
|
+
|
|
132
|
+
code, message = case error_type
|
|
133
|
+
when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
|
|
134
|
+
when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
|
|
135
|
+
when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
|
|
136
|
+
when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
|
|
137
|
+
else [ErrorCode::INTERNAL_ERROR, "Internal error"]
|
|
138
|
+
end
|
|
128
139
|
end
|
|
129
140
|
|
|
141
|
+
data = error.respond_to?(:error_data) && error.error_data ? error.error_data : error.message
|
|
142
|
+
|
|
130
143
|
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
|
|
131
144
|
code: code,
|
|
132
145
|
message: message,
|
|
133
|
-
data:
|
|
146
|
+
data: data,
|
|
134
147
|
})
|
|
135
148
|
end
|
|
136
149
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cancelled_error"
|
|
4
|
+
|
|
5
|
+
module MCP
|
|
6
|
+
class Cancellation
|
|
7
|
+
attr_reader :reason, :request_id
|
|
8
|
+
|
|
9
|
+
def initialize(request_id: nil)
|
|
10
|
+
@request_id = request_id
|
|
11
|
+
@reason = nil
|
|
12
|
+
@cancelled = false
|
|
13
|
+
@callbacks = []
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cancelled?
|
|
18
|
+
@mutex.synchronize { @cancelled }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def cancel(reason: nil)
|
|
22
|
+
callbacks = @mutex.synchronize do
|
|
23
|
+
return false if @cancelled
|
|
24
|
+
|
|
25
|
+
@cancelled = true
|
|
26
|
+
@reason = reason
|
|
27
|
+
@callbacks.tap { @callbacks = [] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
callbacks.each do |callback|
|
|
31
|
+
callback.call(reason)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
MCP.configuration.exception_reporter.call(e, { error: "Cancellation callback failed" })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Registers a callback invoked synchronously on the first `cancel` call.
|
|
40
|
+
# If already cancelled, fires immediately.
|
|
41
|
+
#
|
|
42
|
+
# Returns the block itself as a handle that can be passed to `off_cancel`
|
|
43
|
+
# to deregister it (e.g. when a nested request completes normally and the
|
|
44
|
+
# hook should not fire on a later parent cancellation).
|
|
45
|
+
def on_cancel(&block)
|
|
46
|
+
fire_now = false
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
if @cancelled
|
|
49
|
+
fire_now = true
|
|
50
|
+
else
|
|
51
|
+
@callbacks << block
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
block.call(@reason) if fire_now
|
|
56
|
+
block
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Removes a previously-registered `on_cancel` callback. Returns `true`
|
|
60
|
+
# if the callback was still pending (i.e. had not yet fired), `false`
|
|
61
|
+
# otherwise. Safe to call with `nil`.
|
|
62
|
+
def off_cancel(handle)
|
|
63
|
+
return false unless handle
|
|
64
|
+
|
|
65
|
+
@mutex.synchronize { !@callbacks.delete(handle).nil? }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def raise_if_cancelled!
|
|
69
|
+
raise CancelledError.new(request_id: @request_id, reason: @reason) if cancelled?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class CancelledError < StandardError
|
|
5
|
+
attr_reader :request_id, :reason
|
|
6
|
+
|
|
7
|
+
def initialize(message = "Request was cancelled", request_id: nil, reason: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@request_id = request_id
|
|
10
|
+
@reason = reason
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -1,24 +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
|
-
def initialize(url:, headers: {})
|
|
22
|
+
def initialize(url:, headers: {}, &block)
|
|
11
23
|
@url = url
|
|
12
24
|
@headers = headers
|
|
25
|
+
@faraday_customizer = block
|
|
26
|
+
@session_id = nil
|
|
27
|
+
@protocol_version = nil
|
|
28
|
+
@server_info = nil
|
|
29
|
+
@connected = false
|
|
13
30
|
end
|
|
14
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
|
|
116
|
+
end
|
|
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.
|
|
15
122
|
def send_request(request:)
|
|
16
123
|
method = request[:method] || request["method"]
|
|
17
124
|
params = request[:params] || request["params"]
|
|
18
125
|
|
|
19
|
-
response = client.post("", request)
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
22
132
|
rescue Faraday::BadRequestError => e
|
|
23
133
|
raise RequestHandlerError.new(
|
|
24
134
|
"The #{method} request is invalid",
|
|
@@ -41,12 +151,25 @@ module MCP
|
|
|
41
151
|
original_error: e,
|
|
42
152
|
)
|
|
43
153
|
rescue Faraday::ResourceNotFound => e
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
50
173
|
rescue Faraday::UnprocessableEntityError => e
|
|
51
174
|
raise RequestHandlerError.new(
|
|
52
175
|
"The #{method} request is unprocessable",
|
|
@@ -63,6 +186,31 @@ module MCP
|
|
|
63
186
|
)
|
|
64
187
|
end
|
|
65
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
|
+
|
|
66
214
|
private
|
|
67
215
|
|
|
68
216
|
attr_reader :headers
|
|
@@ -78,9 +226,38 @@ module MCP
|
|
|
78
226
|
headers.each do |key, value|
|
|
79
227
|
faraday.headers[key] = value
|
|
80
228
|
end
|
|
229
|
+
|
|
230
|
+
@faraday_customizer&.call(faraday)
|
|
81
231
|
end
|
|
82
232
|
end
|
|
83
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
|
+
|
|
84
261
|
def require_faraday!
|
|
85
262
|
require "faraday"
|
|
86
263
|
rescue LoadError
|
|
@@ -89,14 +266,56 @@ module MCP
|
|
|
89
266
|
"See https://rubygems.org/gems/faraday for more details."
|
|
90
267
|
end
|
|
91
268
|
|
|
92
|
-
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
|
+
|
|
93
282
|
content_type = response.headers["Content-Type"]
|
|
94
|
-
|
|
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
|
|
95
314
|
|
|
96
315
|
raise RequestHandlerError.new(
|
|
97
|
-
"
|
|
316
|
+
"No valid JSON-RPC response found in SSE stream",
|
|
98
317
|
{ method: method, params: params },
|
|
99
|
-
error_type: :
|
|
318
|
+
error_type: :parse_error,
|
|
100
319
|
)
|
|
101
320
|
end
|
|
102
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)
|