ruby-mcp-client 0.8.0 → 0.9.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 +495 -9
- data/lib/mcp_client/auth/browser_oauth.rb +424 -0
- data/lib/mcp_client/auth/oauth_provider.rb +131 -19
- data/lib/mcp_client/auth.rb +1 -1
- data/lib/mcp_client/client.rb +159 -45
- data/lib/mcp_client/json_rpc_common.rb +3 -1
- data/lib/mcp_client/resource_content.rb +80 -0
- data/lib/mcp_client/resource_template.rb +57 -0
- data/lib/mcp_client/server_base.rb +31 -3
- data/lib/mcp_client/server_factory.rb +4 -2
- data/lib/mcp_client/server_http.rb +150 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +11 -0
- data/lib/mcp_client/server_sse.rb +198 -12
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +5 -0
- data/lib/mcp_client/server_stdio.rb +197 -7
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +8 -1
- data/lib/mcp_client/server_streamable_http.rb +198 -16
- data/lib/mcp_client/tool.rb +40 -4
- data/lib/mcp_client/version.rb +2 -2
- data/lib/mcp_client.rb +2 -0
- metadata +5 -2
|
@@ -11,6 +11,12 @@ require 'faraday/follow_redirects'
|
|
|
11
11
|
module MCPClient
|
|
12
12
|
# Implementation of MCP server that communicates via Server-Sent Events (SSE)
|
|
13
13
|
# Useful for communicating with remote MCP servers over HTTP
|
|
14
|
+
#
|
|
15
|
+
# @note Elicitation Support (MCP 2025-06-18)
|
|
16
|
+
# This transport FULLY supports server-initiated elicitation requests via bidirectional
|
|
17
|
+
# JSON-RPC. The server sends elicitation/create requests via the SSE stream, and the
|
|
18
|
+
# client responds via HTTP POST to the RPC endpoint. This provides full elicitation
|
|
19
|
+
# capability for remote servers.
|
|
14
20
|
class ServerSSE < ServerBase
|
|
15
21
|
require_relative 'server_sse/sse_parser'
|
|
16
22
|
require_relative 'server_sse/json_rpc_transport'
|
|
@@ -98,6 +104,7 @@ module MCPClient
|
|
|
98
104
|
# Time of last activity
|
|
99
105
|
@last_activity_time = Time.now
|
|
100
106
|
@activity_timer_thread = nil
|
|
107
|
+
@elicitation_request_callback = nil # MCP 2025-06-18
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
# Stream tool call fallback for SSE transport (yields single result)
|
|
@@ -161,26 +168,34 @@ module MCPClient
|
|
|
161
168
|
end
|
|
162
169
|
|
|
163
170
|
# List all resources available from the MCP server
|
|
164
|
-
# @
|
|
171
|
+
# @param cursor [String, nil] optional cursor for pagination
|
|
172
|
+
# @return [Hash] result containing resources array and optional nextCursor
|
|
165
173
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
166
174
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
167
175
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource listing
|
|
168
|
-
def list_resources
|
|
176
|
+
def list_resources(cursor: nil)
|
|
169
177
|
@mutex.synchronize do
|
|
170
|
-
return @
|
|
178
|
+
return @resources_result if @resources_result && !cursor
|
|
171
179
|
end
|
|
172
180
|
|
|
173
181
|
begin
|
|
174
182
|
ensure_initialized
|
|
175
183
|
|
|
176
|
-
|
|
184
|
+
params = {}
|
|
185
|
+
params['cursor'] = cursor if cursor
|
|
186
|
+
result = rpc_request('resources/list', params)
|
|
187
|
+
|
|
188
|
+
resources = (result['resources'] || []).map do |resource_data|
|
|
189
|
+
MCPClient::Resource.from_json(resource_data, server: self)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
193
|
+
|
|
177
194
|
@mutex.synchronize do
|
|
178
|
-
@
|
|
179
|
-
MCPClient::Resource.from_json(resource_data, server: self)
|
|
180
|
-
end
|
|
195
|
+
@resources_result = resources_result unless cursor
|
|
181
196
|
end
|
|
182
197
|
|
|
183
|
-
|
|
198
|
+
resources_result
|
|
184
199
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
185
200
|
# Re-raise these errors directly
|
|
186
201
|
raise
|
|
@@ -191,15 +206,15 @@ module MCPClient
|
|
|
191
206
|
|
|
192
207
|
# Read a resource by its URI
|
|
193
208
|
# @param uri [String] the URI of the resource to read
|
|
194
|
-
# @return [
|
|
209
|
+
# @return [Array<MCPClient::ResourceContent>] array of resource contents
|
|
195
210
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
196
211
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
197
212
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource reading
|
|
198
213
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
199
214
|
def read_resource(uri)
|
|
200
|
-
rpc_request('resources/read', {
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
result = rpc_request('resources/read', { uri: uri })
|
|
216
|
+
contents = result['contents'] || []
|
|
217
|
+
contents.map { |content| MCPClient::ResourceContent.from_json(content) }
|
|
203
218
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
204
219
|
# Re-raise connection/transport errors directly to match test expectations
|
|
205
220
|
raise
|
|
@@ -208,6 +223,58 @@ module MCPClient
|
|
|
208
223
|
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
209
224
|
end
|
|
210
225
|
|
|
226
|
+
# List all resource templates available from the MCP server
|
|
227
|
+
# @param cursor [String, nil] optional cursor for pagination
|
|
228
|
+
# @return [Hash] result containing resourceTemplates array and optional nextCursor
|
|
229
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
230
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
231
|
+
def list_resource_templates(cursor: nil)
|
|
232
|
+
ensure_initialized
|
|
233
|
+
params = {}
|
|
234
|
+
params['cursor'] = cursor if cursor
|
|
235
|
+
result = rpc_request('resources/templates/list', params)
|
|
236
|
+
|
|
237
|
+
templates = (result['resourceTemplates'] || []).map do |template_data|
|
|
238
|
+
MCPClient::ResourceTemplate.from_json(template_data, server: self)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
{ 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
242
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
243
|
+
raise
|
|
244
|
+
rescue StandardError => e
|
|
245
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Subscribe to resource updates
|
|
249
|
+
# @param uri [String] the URI of the resource to subscribe to
|
|
250
|
+
# @return [Boolean] true if subscription successful
|
|
251
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
252
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
|
|
253
|
+
def subscribe_resource(uri)
|
|
254
|
+
ensure_initialized
|
|
255
|
+
rpc_request('resources/subscribe', { uri: uri })
|
|
256
|
+
true
|
|
257
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
258
|
+
raise
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Unsubscribe from resource updates
|
|
264
|
+
# @param uri [String] the URI of the resource to unsubscribe from
|
|
265
|
+
# @return [Boolean] true if unsubscription successful
|
|
266
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
267
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
|
|
268
|
+
def unsubscribe_resource(uri)
|
|
269
|
+
ensure_initialized
|
|
270
|
+
rpc_request('resources/unsubscribe', { uri: uri })
|
|
271
|
+
true
|
|
272
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
273
|
+
raise
|
|
274
|
+
rescue StandardError => e
|
|
275
|
+
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
276
|
+
end
|
|
277
|
+
|
|
211
278
|
# List all tools available from the MCP server
|
|
212
279
|
# @return [Array<MCPClient::Tool>] list of available tools
|
|
213
280
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
@@ -349,6 +416,125 @@ module MCPClient
|
|
|
349
416
|
end
|
|
350
417
|
end
|
|
351
418
|
|
|
419
|
+
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
420
|
+
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
421
|
+
# @return [void]
|
|
422
|
+
def on_elicitation_request(&block)
|
|
423
|
+
@elicitation_request_callback = block
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Handle incoming JSON-RPC request from server (MCP 2025-06-18)
|
|
427
|
+
# @param msg [Hash] the JSON-RPC request message
|
|
428
|
+
# @return [void]
|
|
429
|
+
def handle_server_request(msg)
|
|
430
|
+
request_id = msg['id']
|
|
431
|
+
method = msg['method']
|
|
432
|
+
params = msg['params'] || {}
|
|
433
|
+
|
|
434
|
+
@logger.debug("Received server request: #{method} (id: #{request_id})")
|
|
435
|
+
|
|
436
|
+
case method
|
|
437
|
+
when 'elicitation/create'
|
|
438
|
+
handle_elicitation_create(request_id, params)
|
|
439
|
+
else
|
|
440
|
+
# Unknown request method, send error response
|
|
441
|
+
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
442
|
+
end
|
|
443
|
+
rescue StandardError => e
|
|
444
|
+
@logger.error("Error handling server request: #{e.message}")
|
|
445
|
+
send_error_response(request_id, -32_603, "Internal error: #{e.message}")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Handle elicitation/create request from server (MCP 2025-06-18)
|
|
449
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
450
|
+
# @param params [Hash] the elicitation parameters
|
|
451
|
+
# @return [void]
|
|
452
|
+
def handle_elicitation_create(request_id, params)
|
|
453
|
+
# If no callback is registered, decline the request
|
|
454
|
+
unless @elicitation_request_callback
|
|
455
|
+
@logger.warn('Received elicitation request but no callback registered, declining')
|
|
456
|
+
send_elicitation_response(request_id, { 'action' => 'decline' })
|
|
457
|
+
return
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Call the registered callback
|
|
461
|
+
result = @elicitation_request_callback.call(request_id, params)
|
|
462
|
+
|
|
463
|
+
# Send the response back to the server
|
|
464
|
+
send_elicitation_response(request_id, result)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Send elicitation response back to server via HTTP POST (MCP 2025-06-18)
|
|
468
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
469
|
+
# @param result [Hash] the elicitation result (action and optional content)
|
|
470
|
+
# @return [void]
|
|
471
|
+
def send_elicitation_response(request_id, result)
|
|
472
|
+
ensure_initialized
|
|
473
|
+
|
|
474
|
+
response = {
|
|
475
|
+
'jsonrpc' => '2.0',
|
|
476
|
+
'id' => request_id,
|
|
477
|
+
'result' => result
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
# Send response via HTTP POST to the RPC endpoint
|
|
481
|
+
post_jsonrpc_response(response)
|
|
482
|
+
rescue StandardError => e
|
|
483
|
+
@logger.error("Error sending elicitation response: #{e.message}")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Send error response back to server via HTTP POST (MCP 2025-06-18)
|
|
487
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
488
|
+
# @param code [Integer] the error code
|
|
489
|
+
# @param message [String] the error message
|
|
490
|
+
# @return [void]
|
|
491
|
+
def send_error_response(request_id, code, message)
|
|
492
|
+
ensure_initialized
|
|
493
|
+
|
|
494
|
+
response = {
|
|
495
|
+
'jsonrpc' => '2.0',
|
|
496
|
+
'id' => request_id,
|
|
497
|
+
'error' => {
|
|
498
|
+
'code' => code,
|
|
499
|
+
'message' => message
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
# Send response via HTTP POST to the RPC endpoint
|
|
504
|
+
post_jsonrpc_response(response)
|
|
505
|
+
rescue StandardError => e
|
|
506
|
+
@logger.error("Error sending error response: #{e.message}")
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Post a JSON-RPC response message to the server via HTTP
|
|
510
|
+
# @param response [Hash] the JSON-RPC response
|
|
511
|
+
# @return [void]
|
|
512
|
+
# @private
|
|
513
|
+
def post_jsonrpc_response(response)
|
|
514
|
+
unless @rpc_endpoint
|
|
515
|
+
@logger.error('Cannot send response: RPC endpoint not available')
|
|
516
|
+
return
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Use the same connection pattern as post_json_rpc_request
|
|
520
|
+
uri = URI.parse(@base_url)
|
|
521
|
+
base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
522
|
+
@rpc_conn ||= create_json_rpc_connection(base)
|
|
523
|
+
|
|
524
|
+
json_body = JSON.generate(response)
|
|
525
|
+
|
|
526
|
+
@rpc_conn.post do |req|
|
|
527
|
+
req.url @rpc_endpoint
|
|
528
|
+
req.headers['Content-Type'] = 'application/json'
|
|
529
|
+
@headers.each { |k, v| req.headers[k] = v }
|
|
530
|
+
req.body = json_body
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
@logger.debug("Sent response via HTTP POST: #{json_body}")
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
@logger.error("Failed to send response via HTTP POST: #{e.message}")
|
|
536
|
+
end
|
|
537
|
+
|
|
352
538
|
private
|
|
353
539
|
|
|
354
540
|
# Start the SSE thread to listen for events
|
|
@@ -34,6 +34,11 @@ module MCPClient
|
|
|
34
34
|
raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Store server info and capabilities
|
|
38
|
+
result = res['result'] || {}
|
|
39
|
+
@server_info = result['serverInfo']
|
|
40
|
+
@capabilities = result['capabilities']
|
|
41
|
+
|
|
37
42
|
# Send initialized notification
|
|
38
43
|
notif = build_jsonrpc_notification('notifications/initialized', {})
|
|
39
44
|
@stdin.puts(notif.to_json)
|
|
@@ -39,13 +39,24 @@ module MCPClient
|
|
|
39
39
|
@next_id = 1
|
|
40
40
|
@pending = {}
|
|
41
41
|
@initialized = false
|
|
42
|
+
@server_info = nil
|
|
43
|
+
@capabilities = nil
|
|
42
44
|
initialize_logger(logger)
|
|
43
45
|
@max_retries = retries
|
|
44
46
|
@retry_backoff = retry_backoff
|
|
45
47
|
@read_timeout = read_timeout
|
|
46
48
|
@env = env || {}
|
|
49
|
+
@elicitation_request_callback = nil # MCP 2025-06-18
|
|
47
50
|
end
|
|
48
51
|
|
|
52
|
+
# Server info from the initialize response
|
|
53
|
+
# @return [Hash, nil] Server information
|
|
54
|
+
attr_reader :server_info
|
|
55
|
+
|
|
56
|
+
# Server capabilities from the initialize response
|
|
57
|
+
# @return [Hash, nil] Server capabilities
|
|
58
|
+
attr_reader :capabilities
|
|
59
|
+
|
|
49
60
|
# Connect to the MCP server by launching the command process via stdin/stdout
|
|
50
61
|
# @return [Boolean] true if connection was successful
|
|
51
62
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
@@ -85,12 +96,20 @@ module MCPClient
|
|
|
85
96
|
def handle_line(line)
|
|
86
97
|
msg = JSON.parse(line)
|
|
87
98
|
@logger.debug("Received line: #{line.chomp}")
|
|
99
|
+
|
|
100
|
+
# Dispatch JSON-RPC requests from server (has id AND method) - MCP 2025-06-18
|
|
101
|
+
if msg['method'] && msg.key?('id')
|
|
102
|
+
handle_server_request(msg)
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
88
106
|
# Dispatch JSON-RPC notifications (no id, has method)
|
|
89
107
|
if msg['method'] && !msg.key?('id')
|
|
90
108
|
@notification_callback&.call(msg['method'], msg['params'])
|
|
91
109
|
return
|
|
92
110
|
end
|
|
93
|
-
|
|
111
|
+
|
|
112
|
+
# Handle standard JSON-RPC responses (has id, no method)
|
|
94
113
|
id = msg['id']
|
|
95
114
|
return unless id
|
|
96
115
|
|
|
@@ -149,27 +168,32 @@ module MCPClient
|
|
|
149
168
|
end
|
|
150
169
|
|
|
151
170
|
# List all resources available from the MCP server
|
|
152
|
-
# @
|
|
171
|
+
# @param cursor [String, nil] optional cursor for pagination
|
|
172
|
+
# @return [Hash] result containing resources array and optional nextCursor
|
|
153
173
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
154
174
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource listing
|
|
155
|
-
def list_resources
|
|
175
|
+
def list_resources(cursor: nil)
|
|
156
176
|
ensure_initialized
|
|
157
177
|
req_id = next_id
|
|
158
|
-
|
|
178
|
+
params = {}
|
|
179
|
+
params['cursor'] = cursor if cursor
|
|
180
|
+
req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'resources/list', 'params' => params }
|
|
159
181
|
send_request(req)
|
|
160
182
|
res = wait_response(req_id)
|
|
161
183
|
if (err = res['error'])
|
|
162
184
|
raise MCPClient::Errors::ServerError, err['message']
|
|
163
185
|
end
|
|
164
186
|
|
|
165
|
-
|
|
187
|
+
result = res['result'] || {}
|
|
188
|
+
resources = (result['resources'] || []).map { |td| MCPClient::Resource.from_json(td, server: self) }
|
|
189
|
+
{ 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
166
190
|
rescue StandardError => e
|
|
167
191
|
raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
|
|
168
192
|
end
|
|
169
193
|
|
|
170
194
|
# Read a resource by its URI
|
|
171
195
|
# @param uri [String] the URI of the resource to read
|
|
172
|
-
# @return [
|
|
196
|
+
# @return [Array<MCPClient::ResourceContent>] array of resource contents
|
|
173
197
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
174
198
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource reading
|
|
175
199
|
def read_resource(uri)
|
|
@@ -188,11 +212,87 @@ module MCPClient
|
|
|
188
212
|
raise MCPClient::Errors::ServerError, err['message']
|
|
189
213
|
end
|
|
190
214
|
|
|
191
|
-
res['result']
|
|
215
|
+
result = res['result'] || {}
|
|
216
|
+
contents = result['contents'] || []
|
|
217
|
+
contents.map { |content| MCPClient::ResourceContent.from_json(content) }
|
|
192
218
|
rescue StandardError => e
|
|
193
219
|
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
194
220
|
end
|
|
195
221
|
|
|
222
|
+
# List all resource templates available from the MCP server
|
|
223
|
+
# @param cursor [String, nil] optional cursor for pagination
|
|
224
|
+
# @return [Hash] result containing resourceTemplates array and optional nextCursor
|
|
225
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
226
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
227
|
+
def list_resource_templates(cursor: nil)
|
|
228
|
+
ensure_initialized
|
|
229
|
+
req_id = next_id
|
|
230
|
+
params = {}
|
|
231
|
+
params['cursor'] = cursor if cursor
|
|
232
|
+
req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'resources/templates/list', 'params' => params }
|
|
233
|
+
send_request(req)
|
|
234
|
+
res = wait_response(req_id)
|
|
235
|
+
if (err = res['error'])
|
|
236
|
+
raise MCPClient::Errors::ServerError, err['message']
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
result = res['result'] || {}
|
|
240
|
+
templates = (result['resourceTemplates'] || []).map { |td| MCPClient::ResourceTemplate.from_json(td, server: self) }
|
|
241
|
+
{ 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Subscribe to resource updates
|
|
247
|
+
# @param uri [String] the URI of the resource to subscribe to
|
|
248
|
+
# @return [Boolean] true if subscription successful
|
|
249
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
250
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
|
|
251
|
+
def subscribe_resource(uri)
|
|
252
|
+
ensure_initialized
|
|
253
|
+
req_id = next_id
|
|
254
|
+
req = {
|
|
255
|
+
'jsonrpc' => '2.0',
|
|
256
|
+
'id' => req_id,
|
|
257
|
+
'method' => 'resources/subscribe',
|
|
258
|
+
'params' => { 'uri' => uri }
|
|
259
|
+
}
|
|
260
|
+
send_request(req)
|
|
261
|
+
res = wait_response(req_id)
|
|
262
|
+
if (err = res['error'])
|
|
263
|
+
raise MCPClient::Errors::ServerError, err['message']
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
true
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Unsubscribe from resource updates
|
|
272
|
+
# @param uri [String] the URI of the resource to unsubscribe from
|
|
273
|
+
# @return [Boolean] true if unsubscription successful
|
|
274
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
275
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
|
|
276
|
+
def unsubscribe_resource(uri)
|
|
277
|
+
ensure_initialized
|
|
278
|
+
req_id = next_id
|
|
279
|
+
req = {
|
|
280
|
+
'jsonrpc' => '2.0',
|
|
281
|
+
'id' => req_id,
|
|
282
|
+
'method' => 'resources/unsubscribe',
|
|
283
|
+
'params' => { 'uri' => uri }
|
|
284
|
+
}
|
|
285
|
+
send_request(req)
|
|
286
|
+
res = wait_response(req_id)
|
|
287
|
+
if (err = res['error'])
|
|
288
|
+
raise MCPClient::Errors::ServerError, err['message']
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
true
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
294
|
+
end
|
|
295
|
+
|
|
196
296
|
# List all tools available from the MCP server
|
|
197
297
|
# @return [Array<MCPClient::Tool>] list of available tools
|
|
198
298
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
@@ -240,6 +340,96 @@ module MCPClient
|
|
|
240
340
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
241
341
|
end
|
|
242
342
|
|
|
343
|
+
# Register a callback for elicitation requests (MCP 2025-06-18)
|
|
344
|
+
# @param block [Proc] callback that receives (request_id, params) and returns response hash
|
|
345
|
+
# @return [void]
|
|
346
|
+
def on_elicitation_request(&block)
|
|
347
|
+
@elicitation_request_callback = block
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Handle incoming JSON-RPC request from server (MCP 2025-06-18)
|
|
351
|
+
# @param msg [Hash] the JSON-RPC request message
|
|
352
|
+
# @return [void]
|
|
353
|
+
def handle_server_request(msg)
|
|
354
|
+
request_id = msg['id']
|
|
355
|
+
method = msg['method']
|
|
356
|
+
params = msg['params'] || {}
|
|
357
|
+
|
|
358
|
+
@logger.debug("Received server request: #{method} (id: #{request_id})")
|
|
359
|
+
|
|
360
|
+
case method
|
|
361
|
+
when 'elicitation/create'
|
|
362
|
+
handle_elicitation_create(request_id, params)
|
|
363
|
+
else
|
|
364
|
+
# Unknown request method, send error response
|
|
365
|
+
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
366
|
+
end
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
@logger.error("Error handling server request: #{e.message}")
|
|
369
|
+
send_error_response(request_id, -32_603, "Internal error: #{e.message}")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Handle elicitation/create request from server (MCP 2025-06-18)
|
|
373
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
374
|
+
# @param params [Hash] the elicitation parameters
|
|
375
|
+
# @return [void]
|
|
376
|
+
def handle_elicitation_create(request_id, params)
|
|
377
|
+
# If no callback is registered, decline the request
|
|
378
|
+
unless @elicitation_request_callback
|
|
379
|
+
@logger.warn('Received elicitation request but no callback registered, declining')
|
|
380
|
+
send_elicitation_response(request_id, { 'action' => 'decline' })
|
|
381
|
+
return
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Call the registered callback
|
|
385
|
+
result = @elicitation_request_callback.call(request_id, params)
|
|
386
|
+
|
|
387
|
+
# Send the response back to the server
|
|
388
|
+
send_elicitation_response(request_id, result)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Send elicitation response back to server (MCP 2025-06-18)
|
|
392
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
393
|
+
# @param result [Hash] the elicitation result (action and optional content)
|
|
394
|
+
# @return [void]
|
|
395
|
+
def send_elicitation_response(request_id, result)
|
|
396
|
+
response = {
|
|
397
|
+
'jsonrpc' => '2.0',
|
|
398
|
+
'id' => request_id,
|
|
399
|
+
'result' => result
|
|
400
|
+
}
|
|
401
|
+
send_message(response)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Send error response back to server (MCP 2025-06-18)
|
|
405
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
406
|
+
# @param code [Integer] the error code
|
|
407
|
+
# @param message [String] the error message
|
|
408
|
+
# @return [void]
|
|
409
|
+
def send_error_response(request_id, code, message)
|
|
410
|
+
response = {
|
|
411
|
+
'jsonrpc' => '2.0',
|
|
412
|
+
'id' => request_id,
|
|
413
|
+
'error' => {
|
|
414
|
+
'code' => code,
|
|
415
|
+
'message' => message
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
send_message(response)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Send a JSON-RPC message to the server
|
|
422
|
+
# @param message [Hash] the message to send
|
|
423
|
+
# @return [void]
|
|
424
|
+
def send_message(message)
|
|
425
|
+
json = JSON.generate(message)
|
|
426
|
+
@stdin.puts(json)
|
|
427
|
+
@stdin.flush
|
|
428
|
+
@logger.debug("Sent message: #{json}")
|
|
429
|
+
rescue StandardError => e
|
|
430
|
+
@logger.error("Error sending message: #{e.message}")
|
|
431
|
+
end
|
|
432
|
+
|
|
243
433
|
# Clean up the server connection
|
|
244
434
|
# Closes all stdio handles and terminates any running processes and threads
|
|
245
435
|
# @return [void]
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../http_transport_base'
|
|
4
4
|
|
|
5
|
+
require 'zlib'
|
|
6
|
+
require 'stringio'
|
|
7
|
+
|
|
5
8
|
module MCPClient
|
|
6
9
|
class ServerStreamableHTTP
|
|
7
10
|
# JSON-RPC request/notification plumbing for Streamable HTTP transport
|
|
@@ -23,8 +26,12 @@ module MCPClient
|
|
|
23
26
|
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
|
24
27
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
25
28
|
def parse_response(response)
|
|
26
|
-
body = response.body
|
|
29
|
+
body = response.body
|
|
27
30
|
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
|
|
31
|
+
content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
|
|
32
|
+
|
|
33
|
+
body = Zlib::GzipReader.new(StringIO.new(body)).read if content_encoding.include?('gzip')
|
|
34
|
+
body = body&.strip
|
|
28
35
|
|
|
29
36
|
# Determine response format based on Content-Type header per MCP 2025 spec
|
|
30
37
|
data = if content_type.include?('text/event-stream')
|