ruby-mcp-client 1.0.1 → 2.0.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 +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
|
@@ -16,6 +16,7 @@ module MCPClient
|
|
|
16
16
|
|
|
17
17
|
connect
|
|
18
18
|
start_reader
|
|
19
|
+
start_stderr_reader
|
|
19
20
|
perform_initialize
|
|
20
21
|
|
|
21
22
|
@initialized = true
|
|
@@ -34,22 +35,28 @@ module MCPClient
|
|
|
34
35
|
raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
# Store server info and capabilities
|
|
38
|
+
# Store negotiated protocol version, server info and capabilities.
|
|
39
|
+
# Disconnects if the server negotiated a version we cannot speak.
|
|
38
40
|
result = res['result'] || {}
|
|
41
|
+
@protocol_version = validate_protocol_version!(result)
|
|
39
42
|
@server_info = result['serverInfo']
|
|
40
43
|
@capabilities = result['capabilities']
|
|
44
|
+
@instructions = result['instructions']
|
|
41
45
|
|
|
42
46
|
# Send initialized notification
|
|
43
47
|
notif = build_jsonrpc_notification('notifications/initialized', {})
|
|
44
48
|
@stdin.puts(notif.to_json)
|
|
45
49
|
end
|
|
46
50
|
|
|
47
|
-
# Generate a new unique request ID
|
|
51
|
+
# Generate a new unique request ID and mark it as awaiting a response.
|
|
52
|
+
# Registering the id before the request is sent lets the reader thread
|
|
53
|
+
# distinguish expected responses from late/unsolicited ones.
|
|
48
54
|
# @return [Integer] a unique request ID
|
|
49
55
|
def next_id
|
|
50
56
|
@mutex.synchronize do
|
|
51
57
|
id = @next_id
|
|
52
58
|
@next_id += 1
|
|
59
|
+
@awaiting[id] = true
|
|
53
60
|
id
|
|
54
61
|
end
|
|
55
62
|
end
|
|
@@ -62,6 +69,10 @@ module MCPClient
|
|
|
62
69
|
@logger.debug("Sending JSONRPC request: #{req.to_json}")
|
|
63
70
|
@stdin.puts(req.to_json)
|
|
64
71
|
rescue StandardError => e
|
|
72
|
+
# A request that failed to send will never receive a response, so drop
|
|
73
|
+
# its awaiting marker; otherwise a broken transport (e.g. the server
|
|
74
|
+
# exited) would leak an entry per retry/attempt into @awaiting.
|
|
75
|
+
@mutex.synchronize { @awaiting.delete(req['id']) } if req.is_a?(Hash) && req['id']
|
|
65
76
|
raise MCPClient::Errors::TransportError, "Failed to send JSONRPC request: #{e.message}"
|
|
66
77
|
end
|
|
67
78
|
|
|
@@ -69,8 +80,8 @@ module MCPClient
|
|
|
69
80
|
# @param id [Integer] the request ID
|
|
70
81
|
# @return [Hash] the JSON-RPC response message
|
|
71
82
|
# @raise [MCPClient::Errors::TransportError] on timeout
|
|
72
|
-
def wait_response(id)
|
|
73
|
-
deadline = Time.now + @read_timeout
|
|
83
|
+
def wait_response(id, timeout: nil)
|
|
84
|
+
deadline = Time.now + (timeout || @read_timeout)
|
|
74
85
|
@mutex.synchronize do
|
|
75
86
|
until @pending.key?(id)
|
|
76
87
|
remaining = deadline - Time.now
|
|
@@ -78,9 +89,11 @@ module MCPClient
|
|
|
78
89
|
|
|
79
90
|
@cond.wait(@mutex, remaining)
|
|
80
91
|
end
|
|
81
|
-
|
|
82
|
-
@pending
|
|
83
|
-
|
|
92
|
+
# Remove the response and the awaiting marker on both success and
|
|
93
|
+
# timeout so neither @pending nor @awaiting accumulates entries.
|
|
94
|
+
msg = @pending.delete(id)
|
|
95
|
+
@awaiting.delete(id)
|
|
96
|
+
raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
|
|
84
97
|
|
|
85
98
|
msg
|
|
86
99
|
end
|
|
@@ -103,17 +116,37 @@ module MCPClient
|
|
|
103
116
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
104
117
|
# @raise [MCPClient::Errors::TransportError] on transport errors
|
|
105
118
|
# @raise [MCPClient::Errors::ToolCallError] on tool call errors
|
|
106
|
-
def rpc_request(method, params = {})
|
|
119
|
+
def rpc_request(method, params = {}, timeout: nil)
|
|
107
120
|
ensure_initialized
|
|
108
121
|
with_retry do
|
|
109
122
|
req_id = next_id
|
|
110
123
|
req = build_jsonrpc_request(method, params, req_id)
|
|
111
124
|
send_request(req)
|
|
112
|
-
|
|
125
|
+
begin
|
|
126
|
+
res = wait_response(req_id, timeout: timeout)
|
|
127
|
+
rescue MCPClient::Errors::RequestTimeoutError
|
|
128
|
+
# MCP lifecycle: on timeout the sender SHOULD issue a cancellation
|
|
129
|
+
# notification for the abandoned request and stop waiting.
|
|
130
|
+
send_cancellation_notification(req_id) if cancellable_request?(method, params)
|
|
131
|
+
raise
|
|
132
|
+
end
|
|
113
133
|
process_jsonrpc_response(res)
|
|
114
134
|
end
|
|
115
135
|
end
|
|
116
136
|
|
|
137
|
+
# Best-effort notifications/cancelled for a request the client stopped
|
|
138
|
+
# waiting on. Failures are swallowed: the transport may be the reason
|
|
139
|
+
# the request timed out in the first place.
|
|
140
|
+
# @param request_id [Integer] id of the abandoned request
|
|
141
|
+
# @return [void]
|
|
142
|
+
def send_cancellation_notification(request_id)
|
|
143
|
+
notif = build_jsonrpc_notification('notifications/cancelled',
|
|
144
|
+
{ 'requestId' => request_id, 'reason' => 'Request timed out' })
|
|
145
|
+
@stdin.puts(notif.to_json)
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
@logger.debug("Failed to send cancellation notification: #{e.message}")
|
|
148
|
+
end
|
|
149
|
+
|
|
117
150
|
# Send a JSON-RPC notification (no response expected)
|
|
118
151
|
# @param method [String] JSON-RPC method
|
|
119
152
|
# @param params [Hash] parameters for the notification
|
|
@@ -21,6 +21,20 @@ module MCPClient
|
|
|
21
21
|
# Timeout in seconds for responses
|
|
22
22
|
READ_TIMEOUT = 15
|
|
23
23
|
|
|
24
|
+
# Grace period in seconds allowed at each stage of the shutdown sequence
|
|
25
|
+
# (after closing stdin, then after SIGTERM) before escalating further, per
|
|
26
|
+
# MCP 2025-11-25 basic/lifecycle.mdx (Shutdown / stdio): close stdin, wait
|
|
27
|
+
# for the server to exit, send SIGTERM, then SIGKILL if it still runs.
|
|
28
|
+
SHUTDOWN_GRACE_PERIOD = 2
|
|
29
|
+
|
|
30
|
+
# Chunk size (bytes) used when draining the subprocess stderr pipe
|
|
31
|
+
STDERR_READ_CHUNK_SIZE = 8192
|
|
32
|
+
|
|
33
|
+
# Maximum bytes buffered for a single unterminated stderr line before it is
|
|
34
|
+
# flushed. Bounds memory when a server writes to stderr without newlines
|
|
35
|
+
# (e.g. progress output using carriage returns).
|
|
36
|
+
STDERR_MAX_LINE_SIZE = 64 * 1024
|
|
37
|
+
|
|
24
38
|
# Initialize a new ServerStdio instance
|
|
25
39
|
# @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
|
|
26
40
|
# For improved security, passing an Array is recommended to avoid shell injection issues
|
|
@@ -38,6 +52,9 @@ module MCPClient
|
|
|
38
52
|
@cond = ConditionVariable.new
|
|
39
53
|
@next_id = 1
|
|
40
54
|
@pending = {}
|
|
55
|
+
# Ids of requests awaiting a response; used to drop late/unsolicited
|
|
56
|
+
# responses so @pending cannot grow without bound on a long-lived session
|
|
57
|
+
@awaiting = {}
|
|
41
58
|
@initialized = false
|
|
42
59
|
@server_info = nil
|
|
43
60
|
@capabilities = nil
|
|
@@ -49,6 +66,8 @@ module MCPClient
|
|
|
49
66
|
@elicitation_request_callback = nil # MCP 2025-06-18
|
|
50
67
|
@roots_list_request_callback = nil # MCP 2025-06-18
|
|
51
68
|
@sampling_request_callback = nil # MCP 2025-11-25
|
|
69
|
+
@reader_thread = nil
|
|
70
|
+
@stderr_thread = nil
|
|
52
71
|
end
|
|
53
72
|
|
|
54
73
|
# Server info from the initialize response
|
|
@@ -74,11 +93,25 @@ module MCPClient
|
|
|
74
93
|
else
|
|
75
94
|
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command)
|
|
76
95
|
end
|
|
96
|
+
pin_pipe_encodings
|
|
77
97
|
true
|
|
78
98
|
rescue StandardError => e
|
|
79
99
|
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
|
|
80
100
|
end
|
|
81
101
|
|
|
102
|
+
# Pin the subprocess pipe encodings to UTF-8 instead of inheriting the
|
|
103
|
+
# process locale (Encoding.default_external). JSON-RPC messages MUST be
|
|
104
|
+
# UTF-8 encoded (MCP 2025-11-25 basic/transports.mdx); under a non-UTF-8
|
|
105
|
+
# locale (e.g. LANG=C) a valid UTF-8 message would otherwise fail to
|
|
106
|
+
# decode and kill the reader thread. The server MAY also write UTF-8 to
|
|
107
|
+
# stderr, so that pipe is pinned as well.
|
|
108
|
+
# @return [void]
|
|
109
|
+
def pin_pipe_encodings
|
|
110
|
+
[@stdin, @stdout, @stderr].each do |io|
|
|
111
|
+
io&.set_encoding(Encoding::UTF_8)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
82
115
|
# Spawn a reader thread to collect JSON-RPC responses
|
|
83
116
|
# @return [Thread] the reader thread
|
|
84
117
|
def start_reader
|
|
@@ -91,6 +124,36 @@ module MCPClient
|
|
|
91
124
|
end
|
|
92
125
|
end
|
|
93
126
|
|
|
127
|
+
# Spawn a thread to continuously drain the subprocess stderr.
|
|
128
|
+
#
|
|
129
|
+
# The child's stderr pipe has a fixed OS buffer (typically 64KB). If it is
|
|
130
|
+
# never read, a server that logs verbosely to stderr eventually blocks on
|
|
131
|
+
# write once the buffer fills, which stalls the whole subprocess (it stops
|
|
132
|
+
# producing stdout / reading stdin) and deadlocks the client. Draining
|
|
133
|
+
# stderr keeps the pipe empty; lines are surfaced at debug level.
|
|
134
|
+
#
|
|
135
|
+
# Reads happen in bounded chunks (not IO#each_line) so that a server which
|
|
136
|
+
# writes to stderr without newline delimiters cannot make the client buffer
|
|
137
|
+
# a single "line" without limit: any pending fragment larger than
|
|
138
|
+
# STDERR_MAX_LINE_SIZE is flushed rather than retained.
|
|
139
|
+
# @return [Thread] the stderr reader thread
|
|
140
|
+
def start_stderr_reader
|
|
141
|
+
@stderr_thread = Thread.new do
|
|
142
|
+
buffer = +''
|
|
143
|
+
loop do
|
|
144
|
+
buffer << @stderr.readpartial(STDERR_READ_CHUNK_SIZE)
|
|
145
|
+
flush_stderr_lines(buffer)
|
|
146
|
+
flush_stderr_overflow(buffer)
|
|
147
|
+
end
|
|
148
|
+
rescue IOError
|
|
149
|
+
# EOFError (a subclass of IOError) on EOF, or IOError on close;
|
|
150
|
+
# emit any trailing partial line before exiting
|
|
151
|
+
@logger.debug("[stderr] #{buffer.chomp}") if buffer && !buffer.empty?
|
|
152
|
+
rescue StandardError
|
|
153
|
+
# reader aborted unexpectedly; nothing actionable
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
94
157
|
# Handle a line of output from the stdio server
|
|
95
158
|
# Parses JSON-RPC messages and adds them to pending responses
|
|
96
159
|
# @param line [String] line of output to parse
|
|
@@ -99,6 +162,13 @@ module MCPClient
|
|
|
99
162
|
msg = JSON.parse(line)
|
|
100
163
|
@logger.debug("Received line: #{line.chomp}")
|
|
101
164
|
|
|
165
|
+
# A JSON-parseable line that is not an object cannot be a JSON-RPC
|
|
166
|
+
# message; skip it rather than raising inside the reader thread
|
|
167
|
+
unless msg.is_a?(Hash)
|
|
168
|
+
@logger.debug("Skipping non-object JSON-RPC line: #{line.chomp}")
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
102
172
|
# Dispatch JSON-RPC requests from server (has id AND method) - MCP 2025-06-18
|
|
103
173
|
if msg['method'] && msg.key?('id')
|
|
104
174
|
handle_server_request(msg)
|
|
@@ -116,11 +186,19 @@ module MCPClient
|
|
|
116
186
|
return unless id
|
|
117
187
|
|
|
118
188
|
@mutex.synchronize do
|
|
119
|
-
|
|
120
|
-
|
|
189
|
+
# Only retain a response that corresponds to an outstanding request.
|
|
190
|
+
# Late responses (arriving after the caller timed out) and unsolicited
|
|
191
|
+
# responses are dropped so @pending cannot grow without bound.
|
|
192
|
+
if @awaiting.key?(id)
|
|
193
|
+
@pending[id] = msg
|
|
194
|
+
@cond.broadcast
|
|
195
|
+
else
|
|
196
|
+
@logger.debug("Discarding response for unknown or expired request id=#{id}")
|
|
197
|
+
end
|
|
121
198
|
end
|
|
122
|
-
rescue JSON::ParserError
|
|
123
|
-
# Skip non-JSONRPC lines in the output stream
|
|
199
|
+
rescue JSON::ParserError, EncodingError
|
|
200
|
+
# Skip non-JSONRPC or undecodable lines in the output stream so a single
|
|
201
|
+
# bad line cannot kill the reader thread
|
|
124
202
|
end
|
|
125
203
|
|
|
126
204
|
# List all prompts available from the MCP server
|
|
@@ -129,15 +207,21 @@ module MCPClient
|
|
|
129
207
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
|
|
130
208
|
def list_prompts
|
|
131
209
|
ensure_initialized
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
210
|
+
collect_paginated('prompts') do |cursor|
|
|
211
|
+
params = {}
|
|
212
|
+
params['cursor'] = cursor if cursor
|
|
213
|
+
req_id = next_id
|
|
214
|
+
req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'prompts/list', 'params' => params }
|
|
215
|
+
send_request(req)
|
|
216
|
+
res = wait_response(req_id)
|
|
217
|
+
if (err = res['error'])
|
|
218
|
+
raise MCPClient::Errors::ServerError, err['message']
|
|
219
|
+
end
|
|
139
220
|
|
|
140
|
-
|
|
221
|
+
result = res['result'] || {}
|
|
222
|
+
prompts = (result['prompts'] || []).map { |td| MCPClient::Prompt.from_json(td, server: self) }
|
|
223
|
+
[prompts, result['nextCursor']]
|
|
224
|
+
end
|
|
141
225
|
rescue StandardError => e
|
|
142
226
|
raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
|
|
143
227
|
end
|
|
@@ -156,7 +240,7 @@ module MCPClient
|
|
|
156
240
|
'jsonrpc' => '2.0',
|
|
157
241
|
'id' => req_id,
|
|
158
242
|
'method' => 'prompts/get',
|
|
159
|
-
'params' =>
|
|
243
|
+
'params' => build_named_request_params(prompt_name, parameters)
|
|
160
244
|
}
|
|
161
245
|
send_request(req)
|
|
162
246
|
res = wait_response(req_id)
|
|
@@ -252,6 +336,7 @@ module MCPClient
|
|
|
252
336
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
|
|
253
337
|
def subscribe_resource(uri)
|
|
254
338
|
ensure_initialized
|
|
339
|
+
require_capability!('resources', 'subscribe', method: 'resources/subscribe')
|
|
255
340
|
req_id = next_id
|
|
256
341
|
req = {
|
|
257
342
|
'jsonrpc' => '2.0',
|
|
@@ -266,6 +351,8 @@ module MCPClient
|
|
|
266
351
|
end
|
|
267
352
|
|
|
268
353
|
true
|
|
354
|
+
rescue MCPClient::Errors::CapabilityError
|
|
355
|
+
raise
|
|
269
356
|
rescue StandardError => e
|
|
270
357
|
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
271
358
|
end
|
|
@@ -277,6 +364,7 @@ module MCPClient
|
|
|
277
364
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
|
|
278
365
|
def unsubscribe_resource(uri)
|
|
279
366
|
ensure_initialized
|
|
367
|
+
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
280
368
|
req_id = next_id
|
|
281
369
|
req = {
|
|
282
370
|
'jsonrpc' => '2.0',
|
|
@@ -291,6 +379,8 @@ module MCPClient
|
|
|
291
379
|
end
|
|
292
380
|
|
|
293
381
|
true
|
|
382
|
+
rescue MCPClient::Errors::CapabilityError
|
|
383
|
+
raise
|
|
294
384
|
rescue StandardError => e
|
|
295
385
|
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
296
386
|
end
|
|
@@ -301,16 +391,22 @@ module MCPClient
|
|
|
301
391
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
|
302
392
|
def list_tools
|
|
303
393
|
ensure_initialized
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
394
|
+
collect_paginated('tools') do |cursor|
|
|
395
|
+
params = {}
|
|
396
|
+
params['cursor'] = cursor if cursor
|
|
397
|
+
req_id = next_id
|
|
398
|
+
# JSON-RPC method for listing tools
|
|
399
|
+
req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'tools/list', 'params' => params }
|
|
400
|
+
send_request(req)
|
|
401
|
+
res = wait_response(req_id)
|
|
402
|
+
if (err = res['error'])
|
|
403
|
+
raise MCPClient::Errors::ServerError, err['message']
|
|
404
|
+
end
|
|
312
405
|
|
|
313
|
-
|
|
406
|
+
result = res['result'] || {}
|
|
407
|
+
tools = (result['tools'] || []).map { |td| MCPClient::Tool.from_json(td, server: self) }
|
|
408
|
+
[tools, result['nextCursor']]
|
|
409
|
+
end
|
|
314
410
|
rescue StandardError => e
|
|
315
411
|
raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
|
|
316
412
|
end
|
|
@@ -329,7 +425,7 @@ module MCPClient
|
|
|
329
425
|
'jsonrpc' => '2.0',
|
|
330
426
|
'id' => req_id,
|
|
331
427
|
'method' => 'tools/call',
|
|
332
|
-
'params' =>
|
|
428
|
+
'params' => build_named_request_params(tool_name, parameters)
|
|
333
429
|
}
|
|
334
430
|
send_request(req)
|
|
335
431
|
res = wait_response(req_id)
|
|
@@ -350,6 +446,7 @@ module MCPClient
|
|
|
350
446
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
351
447
|
def complete(ref:, argument:, context: nil)
|
|
352
448
|
ensure_initialized
|
|
449
|
+
require_capability!('completions', method: 'completion/complete')
|
|
353
450
|
req_id = next_id
|
|
354
451
|
params = { 'ref' => ref, 'argument' => argument }
|
|
355
452
|
params['context'] = context if context
|
|
@@ -366,6 +463,8 @@ module MCPClient
|
|
|
366
463
|
end
|
|
367
464
|
|
|
368
465
|
res.dig('result', 'completion') || { 'values' => [] }
|
|
466
|
+
rescue MCPClient::Errors::CapabilityError
|
|
467
|
+
raise
|
|
369
468
|
rescue StandardError => e
|
|
370
469
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
371
470
|
end
|
|
@@ -377,6 +476,7 @@ module MCPClient
|
|
|
377
476
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
378
477
|
def log_level=(level)
|
|
379
478
|
ensure_initialized
|
|
479
|
+
require_capability!('logging', method: 'logging/setLevel')
|
|
380
480
|
req_id = next_id
|
|
381
481
|
req = {
|
|
382
482
|
'jsonrpc' => '2.0',
|
|
@@ -391,6 +491,8 @@ module MCPClient
|
|
|
391
491
|
end
|
|
392
492
|
|
|
393
493
|
res['result'] || {}
|
|
494
|
+
rescue MCPClient::Errors::CapabilityError
|
|
495
|
+
raise
|
|
394
496
|
rescue StandardError => e
|
|
395
497
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
396
498
|
end
|
|
@@ -427,6 +529,8 @@ module MCPClient
|
|
|
427
529
|
@logger.debug("Received server request: #{method} (id: #{request_id})")
|
|
428
530
|
|
|
429
531
|
case method
|
|
532
|
+
when 'ping'
|
|
533
|
+
handle_ping(request_id)
|
|
430
534
|
when 'elicitation/create'
|
|
431
535
|
handle_elicitation_create(request_id, params)
|
|
432
536
|
when 'roots/list'
|
|
@@ -442,23 +546,37 @@ module MCPClient
|
|
|
442
546
|
send_error_response(request_id, -32_603, "Internal error: #{e.message}")
|
|
443
547
|
end
|
|
444
548
|
|
|
549
|
+
# Handle a server-initiated ping request (MCP ping utility)
|
|
550
|
+
# The receiver MUST respond promptly with an empty result.
|
|
551
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
552
|
+
# @return [void]
|
|
553
|
+
def handle_ping(request_id)
|
|
554
|
+
response = {
|
|
555
|
+
'jsonrpc' => '2.0',
|
|
556
|
+
'id' => request_id,
|
|
557
|
+
'result' => {}
|
|
558
|
+
}
|
|
559
|
+
send_message(response)
|
|
560
|
+
end
|
|
561
|
+
|
|
445
562
|
# Handle elicitation/create request from server (MCP 2025-06-18)
|
|
446
563
|
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
447
564
|
# @param params [Hash] the elicitation parameters
|
|
448
565
|
# @return [void]
|
|
449
566
|
def handle_elicitation_create(request_id, params)
|
|
450
|
-
#
|
|
567
|
+
# Without a callback there is no user to interact with: answer with a
|
|
568
|
+
# JSON-RPC error rather than fabricating a user "decline".
|
|
451
569
|
unless @elicitation_request_callback
|
|
452
|
-
@logger.warn('Received elicitation request but no callback registered
|
|
453
|
-
|
|
570
|
+
@logger.warn('Received elicitation request but no callback registered')
|
|
571
|
+
send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
|
|
454
572
|
return
|
|
455
573
|
end
|
|
456
574
|
|
|
457
575
|
# Call the registered callback
|
|
458
576
|
result = @elicitation_request_callback.call(request_id, params)
|
|
459
577
|
|
|
460
|
-
# Send the response back to the server
|
|
461
|
-
send_elicitation_response(request_id, result)
|
|
578
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
579
|
+
send_elicitation_response(request_id, merge_related_task_meta(result, params))
|
|
462
580
|
end
|
|
463
581
|
|
|
464
582
|
# Handle roots/list request from server (MCP 2025-06-18)
|
|
@@ -476,8 +594,8 @@ module MCPClient
|
|
|
476
594
|
# Call the registered callback
|
|
477
595
|
result = @roots_list_request_callback.call(request_id, params)
|
|
478
596
|
|
|
479
|
-
# Send the response back to the server
|
|
480
|
-
send_roots_list_response(request_id, result)
|
|
597
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
598
|
+
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
481
599
|
end
|
|
482
600
|
|
|
483
601
|
# Handle sampling/createMessage request from server (MCP 2025-11-25)
|
|
@@ -495,8 +613,8 @@ module MCPClient
|
|
|
495
613
|
# Call the registered callback
|
|
496
614
|
result = @sampling_request_callback.call(request_id, params)
|
|
497
615
|
|
|
498
|
-
# Send the response back to the server
|
|
499
|
-
send_sampling_response(request_id, result)
|
|
616
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
617
|
+
send_sampling_response(request_id, merge_related_task_meta(result, params))
|
|
500
618
|
end
|
|
501
619
|
|
|
502
620
|
# Send roots/list response back to server (MCP 2025-06-18)
|
|
@@ -536,6 +654,14 @@ module MCPClient
|
|
|
536
654
|
# @param result [Hash] the elicitation result (action and optional content)
|
|
537
655
|
# @return [void]
|
|
538
656
|
def send_elicitation_response(request_id, result)
|
|
657
|
+
# Error-shaped results become JSON-RPC error responses (e.g. -32602 for
|
|
658
|
+
# an undeclared elicitation mode), mirroring the sampling error path.
|
|
659
|
+
if result.is_a?(Hash) && result['error']
|
|
660
|
+
send_error_response(request_id, result['error']['code'] || -32_603,
|
|
661
|
+
result['error']['message'] || 'Elicitation error')
|
|
662
|
+
return
|
|
663
|
+
end
|
|
664
|
+
|
|
539
665
|
response = {
|
|
540
666
|
'jsonrpc' => '2.0',
|
|
541
667
|
'id' => request_id,
|
|
@@ -573,24 +699,77 @@ module MCPClient
|
|
|
573
699
|
@logger.error("Error sending message: #{e.message}")
|
|
574
700
|
end
|
|
575
701
|
|
|
702
|
+
# Emit and remove all newline-terminated lines from the stderr buffer.
|
|
703
|
+
# @param buffer [String] mutable buffer of accumulated stderr bytes
|
|
704
|
+
# @return [void]
|
|
705
|
+
def flush_stderr_lines(buffer)
|
|
706
|
+
while (newline_index = buffer.index("\n"))
|
|
707
|
+
line = buffer.slice!(0, newline_index + 1)
|
|
708
|
+
@logger.debug("[stderr] #{line.chomp}")
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Flush an unterminated stderr fragment that has grown past the size cap,
|
|
713
|
+
# so a newline-less stderr stream cannot buffer without bound.
|
|
714
|
+
# @param buffer [String] mutable buffer of accumulated stderr bytes
|
|
715
|
+
# @return [void]
|
|
716
|
+
def flush_stderr_overflow(buffer)
|
|
717
|
+
return if buffer.bytesize <= STDERR_MAX_LINE_SIZE
|
|
718
|
+
|
|
719
|
+
@logger.debug("[stderr] #{buffer}")
|
|
720
|
+
buffer.clear
|
|
721
|
+
end
|
|
722
|
+
|
|
576
723
|
# Clean up the server connection
|
|
577
724
|
# Closes all stdio handles and terminates any running processes and threads
|
|
725
|
+
# following the MCP 2025-11-25 stdio shutdown sequence (basic/lifecycle.mdx):
|
|
726
|
+
# close stdin, wait for the server to exit, send SIGTERM if it does not exit
|
|
727
|
+
# within a reasonable time, then SIGKILL if it still does not exit.
|
|
578
728
|
# @return [void]
|
|
579
729
|
def cleanup
|
|
580
730
|
return unless @stdin
|
|
581
731
|
|
|
582
732
|
@stdin.close unless @stdin.closed?
|
|
733
|
+
terminate_server_process
|
|
583
734
|
@stdout.close unless @stdout.closed?
|
|
584
735
|
@stderr.close unless @stderr.closed?
|
|
585
|
-
if @wait_thread&.alive?
|
|
586
|
-
Process.kill('TERM', @wait_thread.pid)
|
|
587
|
-
@wait_thread.join(1)
|
|
588
|
-
end
|
|
589
736
|
@reader_thread&.kill
|
|
737
|
+
@stderr_thread&.kill
|
|
590
738
|
rescue StandardError
|
|
591
739
|
# Clean up resources during unexpected termination
|
|
592
740
|
ensure
|
|
593
|
-
|
|
741
|
+
# Release any buffered responses / awaiting markers
|
|
742
|
+
@mutex.synchronize do
|
|
743
|
+
@pending.clear
|
|
744
|
+
@awaiting.clear
|
|
745
|
+
end
|
|
746
|
+
@stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Terminate the spawned server process per the MCP 2025-11-25 stdio
|
|
750
|
+
# shutdown sequence (basic/lifecycle.mdx): stdin has already been closed,
|
|
751
|
+
# so wait for the process to exit on its own; if it does not exit within
|
|
752
|
+
# the grace period send SIGTERM, wait again, and finally send SIGKILL.
|
|
753
|
+
# @return [void]
|
|
754
|
+
def terminate_server_process
|
|
755
|
+
return unless @wait_thread
|
|
756
|
+
return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
757
|
+
|
|
758
|
+
signal_server_process('TERM')
|
|
759
|
+
return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
760
|
+
|
|
761
|
+
signal_server_process('KILL')
|
|
762
|
+
@wait_thread.join(SHUTDOWN_GRACE_PERIOD)
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Send a signal to the server process, tolerating a process that has
|
|
766
|
+
# already exited or cannot be signalled.
|
|
767
|
+
# @param signal [String] signal name, e.g. 'TERM' or 'KILL'
|
|
768
|
+
# @return [void]
|
|
769
|
+
def signal_server_process(signal)
|
|
770
|
+
Process.kill(signal, @wait_thread.pid)
|
|
771
|
+
rescue Errno::ESRCH, Errno::EPERM => e
|
|
772
|
+
@logger.debug("Could not send SIG#{signal} to server process: #{e.class}")
|
|
594
773
|
end
|
|
595
774
|
end
|
|
596
775
|
end
|