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
data/lib/mcp_client/client.rb
CHANGED
|
@@ -21,7 +21,8 @@ module MCPClient
|
|
|
21
21
|
# Initialize a new MCPClient::Client
|
|
22
22
|
# @param mcp_server_configs [Array<Hash>] configurations for MCP servers
|
|
23
23
|
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
|
24
|
-
|
|
24
|
+
# @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
|
|
25
|
+
def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil)
|
|
25
26
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
26
27
|
@logger.progname = self.class.name
|
|
27
28
|
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
|
@@ -34,6 +35,8 @@ module MCPClient
|
|
|
34
35
|
@resource_cache = {}
|
|
35
36
|
# JSON-RPC notification listeners
|
|
36
37
|
@notification_listeners = []
|
|
38
|
+
# Elicitation handler (MCP 2025-06-18)
|
|
39
|
+
@elicitation_handler = elicitation_handler
|
|
37
40
|
# Register default and user-defined notification handlers on each server
|
|
38
41
|
@servers.each do |server|
|
|
39
42
|
server.on_notification do |method, params|
|
|
@@ -42,6 +45,10 @@ module MCPClient
|
|
|
42
45
|
# Invoke user-defined listeners
|
|
43
46
|
@notification_listeners.each { |cb| cb.call(server, method, params) }
|
|
44
47
|
end
|
|
48
|
+
# Register elicitation handler on each server
|
|
49
|
+
if server.respond_to?(:on_elicitation_request)
|
|
50
|
+
server.on_elicitation_request(&method(:handle_elicitation_request))
|
|
51
|
+
end
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -126,17 +133,32 @@ module MCPClient
|
|
|
126
133
|
|
|
127
134
|
# Lists all available resources from all connected MCP servers
|
|
128
135
|
# @param cache [Boolean] whether to use cached resources or fetch fresh
|
|
129
|
-
# @
|
|
136
|
+
# @param cursor [String, nil] optional cursor for pagination (only works with single server)
|
|
137
|
+
# @return [Hash] result containing 'resources' array and optional 'nextCursor'
|
|
130
138
|
# @raise [MCPClient::Errors::ConnectionError] on authorization failures
|
|
131
139
|
# @raise [MCPClient::Errors::ResourceReadError] if no resources could be retrieved from any server
|
|
132
|
-
def list_resources(cache: true)
|
|
133
|
-
|
|
140
|
+
def list_resources(cache: true, cursor: nil)
|
|
141
|
+
# If cursor is provided, we can only query one server (the one that provided the cursor)
|
|
142
|
+
# This is a limitation of aggregating multiple servers
|
|
143
|
+
if cursor
|
|
144
|
+
# For now, just use the first server when cursor is provided
|
|
145
|
+
# In a real implementation, you'd need to track which server the cursor came from
|
|
146
|
+
return servers.first.list_resources(cursor: cursor) if servers.any?
|
|
147
|
+
|
|
148
|
+
return { 'resources' => [], 'nextCursor' => nil }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Use cache if available and no cursor
|
|
152
|
+
return { 'resources' => @resource_cache.values, 'nextCursor' => nil } if cache && !@resource_cache.empty?
|
|
134
153
|
|
|
135
154
|
resources = []
|
|
136
155
|
connection_errors = []
|
|
137
156
|
|
|
138
157
|
servers.each do |server|
|
|
139
|
-
server.list_resources
|
|
158
|
+
result = server.list_resources
|
|
159
|
+
resource_list = result['resources'] || []
|
|
160
|
+
|
|
161
|
+
resource_list.each do |resource|
|
|
140
162
|
cache_key = cache_key_for(server, resource.uri)
|
|
141
163
|
@resource_cache[cache_key] = resource
|
|
142
164
|
resources << resource
|
|
@@ -152,7 +174,8 @@ module MCPClient
|
|
|
152
174
|
@logger.error("Server error: #{e.message}")
|
|
153
175
|
end
|
|
154
176
|
|
|
155
|
-
|
|
177
|
+
# Return hash format consistent with server methods
|
|
178
|
+
{ 'resources' => resources, 'nextCursor' => nil }
|
|
156
179
|
end
|
|
157
180
|
|
|
158
181
|
# Reads a specific resource by URI
|
|
@@ -160,46 +183,16 @@ module MCPClient
|
|
|
160
183
|
# @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
|
|
161
184
|
# @return [Object] the resource contents
|
|
162
185
|
def read_resource(uri, server: nil)
|
|
163
|
-
|
|
186
|
+
result = list_resources
|
|
187
|
+
resources = result['resources'] || []
|
|
164
188
|
|
|
165
|
-
if server
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
unless resource
|
|
171
|
-
raise MCPClient::Errors::ResourceNotFound,
|
|
172
|
-
"Resource '#{uri}' not found on server '#{srv.name || srv.class.name}'"
|
|
173
|
-
end
|
|
174
|
-
else
|
|
175
|
-
# Find the resource across all servers
|
|
176
|
-
matching_resources = resources.select { |r| r.uri == uri }
|
|
177
|
-
|
|
178
|
-
if matching_resources.empty?
|
|
179
|
-
raise MCPClient::Errors::ResourceNotFound, "Resource '#{uri}' not found"
|
|
180
|
-
elsif matching_resources.size > 1
|
|
181
|
-
# If multiple matches, disambiguate with server names
|
|
182
|
-
server_names = matching_resources.map { |r| r.server&.name || 'unnamed' }
|
|
183
|
-
raise MCPClient::Errors::AmbiguousResourceURI,
|
|
184
|
-
"Multiple resources with URI '#{uri}' found across servers (#{server_names.join(', ')}). " \
|
|
185
|
-
"Please specify a server using the 'server' parameter."
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
resource = matching_resources.first
|
|
189
|
-
end
|
|
189
|
+
resource = if server
|
|
190
|
+
find_resource_on_server(uri, resources, server)
|
|
191
|
+
else
|
|
192
|
+
find_resource_across_servers(uri, resources)
|
|
193
|
+
end
|
|
190
194
|
|
|
191
|
-
|
|
192
|
-
server = resource.server
|
|
193
|
-
raise MCPClient::Errors::ServerNotFound, "No server found for resource '#{uri}'" unless server
|
|
194
|
-
|
|
195
|
-
begin
|
|
196
|
-
server.read_resource(uri)
|
|
197
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
198
|
-
# Add server identity information to the error for better context
|
|
199
|
-
server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
|
|
200
|
-
raise MCPClient::Errors::ResourceReadError,
|
|
201
|
-
"Error reading resource '#{uri}': #{e.message} (Server: #{server_id})"
|
|
202
|
-
end
|
|
195
|
+
execute_resource_read(resource, uri)
|
|
203
196
|
end
|
|
204
197
|
|
|
205
198
|
# Lists all available tools from all connected MCP servers
|
|
@@ -478,7 +471,7 @@ module MCPClient
|
|
|
478
471
|
case method
|
|
479
472
|
when 'notifications/tools/list_changed'
|
|
480
473
|
logger.warn("[#{server_id}] Tool list has changed, clearing tool cache")
|
|
481
|
-
|
|
474
|
+
@tool_cache.clear
|
|
482
475
|
when 'notifications/resources/updated'
|
|
483
476
|
logger.warn("[#{server_id}] Resource #{params['uri']} updated")
|
|
484
477
|
when 'notifications/prompts/list_changed'
|
|
@@ -556,5 +549,126 @@ module MCPClient
|
|
|
556
549
|
server_id = server.object_id.to_s
|
|
557
550
|
"#{server_id}:#{item_id}"
|
|
558
551
|
end
|
|
552
|
+
|
|
553
|
+
# Find a resource on a specific server
|
|
554
|
+
# @param uri [String] the URI of the resource
|
|
555
|
+
# @param resources [Array<Resource>] available resources
|
|
556
|
+
# @param server [String, Symbol, Integer, MCPClient::ServerBase] server selector
|
|
557
|
+
# @return [Resource] the found resource
|
|
558
|
+
# @raise [MCPClient::Errors::ResourceNotFound] if resource not found
|
|
559
|
+
def find_resource_on_server(uri, resources, server)
|
|
560
|
+
srv = select_server(server)
|
|
561
|
+
resource = resources.find { |r| r.uri == uri && r.server == srv }
|
|
562
|
+
unless resource
|
|
563
|
+
raise MCPClient::Errors::ResourceNotFound,
|
|
564
|
+
"Resource '#{uri}' not found on server '#{srv.name || srv.class.name}'"
|
|
565
|
+
end
|
|
566
|
+
resource
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Find a resource across all servers
|
|
570
|
+
# @param uri [String] the URI of the resource
|
|
571
|
+
# @param resources [Array<Resource>] available resources
|
|
572
|
+
# @return [Resource] the found resource
|
|
573
|
+
# @raise [MCPClient::Errors::ResourceNotFound] if resource not found
|
|
574
|
+
# @raise [MCPClient::Errors::AmbiguousResourceURI] if multiple resources found
|
|
575
|
+
def find_resource_across_servers(uri, resources)
|
|
576
|
+
matching_resources = resources.select { |r| r.uri == uri }
|
|
577
|
+
|
|
578
|
+
if matching_resources.empty?
|
|
579
|
+
raise MCPClient::Errors::ResourceNotFound, "Resource '#{uri}' not found"
|
|
580
|
+
elsif matching_resources.size > 1
|
|
581
|
+
server_names = matching_resources.map { |r| r.server&.name || 'unnamed' }
|
|
582
|
+
raise MCPClient::Errors::AmbiguousResourceURI,
|
|
583
|
+
"Multiple resources with URI '#{uri}' found across servers (#{server_names.join(', ')}). " \
|
|
584
|
+
"Please specify a server using the 'server' parameter."
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
matching_resources.first
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Execute the resource read operation
|
|
591
|
+
# @param resource [Resource] the resource to read
|
|
592
|
+
# @param uri [String] the URI of the resource
|
|
593
|
+
# @return [Object] the resource contents
|
|
594
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server found
|
|
595
|
+
# @raise [MCPClient::Errors::ResourceReadError] on read errors
|
|
596
|
+
def execute_resource_read(resource, uri)
|
|
597
|
+
server = resource.server
|
|
598
|
+
raise MCPClient::Errors::ServerNotFound, "No server found for resource '#{uri}'" unless server
|
|
599
|
+
|
|
600
|
+
begin
|
|
601
|
+
server.read_resource(uri)
|
|
602
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
603
|
+
server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
|
|
604
|
+
raise MCPClient::Errors::ResourceReadError,
|
|
605
|
+
"Error reading resource '#{uri}': #{e.message} (Server: #{server_id})"
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Handle elicitation request from server (MCP 2025-06-18)
|
|
610
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
611
|
+
# @param params [Hash] the elicitation parameters
|
|
612
|
+
# @return [Hash] the elicitation response
|
|
613
|
+
def handle_elicitation_request(_request_id, params)
|
|
614
|
+
# If no handler is configured, decline the request
|
|
615
|
+
unless @elicitation_handler
|
|
616
|
+
@logger.warn('Received elicitation request but no handler configured, declining')
|
|
617
|
+
return { 'action' => 'decline' }
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
message = params['message']
|
|
621
|
+
schema = params['schema'] || params['requestedSchema']
|
|
622
|
+
metadata = params['metadata']
|
|
623
|
+
|
|
624
|
+
begin
|
|
625
|
+
# Call the user-defined handler
|
|
626
|
+
result = case @elicitation_handler.arity
|
|
627
|
+
when 0
|
|
628
|
+
@elicitation_handler.call
|
|
629
|
+
when 1
|
|
630
|
+
@elicitation_handler.call(message)
|
|
631
|
+
when 2, -1
|
|
632
|
+
@elicitation_handler.call(message, schema)
|
|
633
|
+
else
|
|
634
|
+
@elicitation_handler.call(message, schema, metadata)
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Validate and format response
|
|
638
|
+
case result
|
|
639
|
+
when Hash
|
|
640
|
+
if result['action']
|
|
641
|
+
normalised_action_response(result)
|
|
642
|
+
elsif result[:action]
|
|
643
|
+
# Convert symbol keys to strings
|
|
644
|
+
{
|
|
645
|
+
'action' => result[:action].to_s,
|
|
646
|
+
'content' => result[:content]
|
|
647
|
+
}.compact.then { |payload| normalised_action_response(payload) }
|
|
648
|
+
else
|
|
649
|
+
# Assume it's content for an accept action
|
|
650
|
+
{ 'action' => 'accept', 'content' => result }
|
|
651
|
+
end
|
|
652
|
+
when nil
|
|
653
|
+
{ 'action' => 'cancel' }
|
|
654
|
+
else
|
|
655
|
+
{ 'action' => 'accept', 'content' => result }
|
|
656
|
+
end
|
|
657
|
+
rescue StandardError => e
|
|
658
|
+
@logger.error("Elicitation handler error: #{e.message}")
|
|
659
|
+
@logger.debug(e.backtrace.join("\n"))
|
|
660
|
+
{ 'action' => 'decline' }
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Ensure the action value conforms to MCP spec (accept, decline, cancel)
|
|
665
|
+
# Falls back to accept for unknown action values.
|
|
666
|
+
def normalised_action_response(result)
|
|
667
|
+
action = result['action']
|
|
668
|
+
return result if %w[accept decline cancel].include?(action)
|
|
669
|
+
|
|
670
|
+
@logger.warn("Unknown elicitation action '#{action}', defaulting to accept")
|
|
671
|
+
result.merge('action' => 'accept')
|
|
672
|
+
end
|
|
559
673
|
end
|
|
560
674
|
end
|
|
@@ -64,7 +64,9 @@ module MCPClient
|
|
|
64
64
|
def initialization_params
|
|
65
65
|
{
|
|
66
66
|
'protocolVersion' => MCPClient::PROTOCOL_VERSION,
|
|
67
|
-
'capabilities' => {
|
|
67
|
+
'capabilities' => {
|
|
68
|
+
'elicitation' => {} # MCP 2025-06-18: Support for server-initiated user interactions
|
|
69
|
+
},
|
|
68
70
|
'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
|
|
69
71
|
}
|
|
70
72
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Representation of MCP resource content
|
|
5
|
+
# Resources can contain either text or binary data
|
|
6
|
+
class ResourceContent
|
|
7
|
+
# @!attribute [r] uri
|
|
8
|
+
# @return [String] unique identifier for the resource
|
|
9
|
+
# @!attribute [r] name
|
|
10
|
+
# @return [String] the name of the resource
|
|
11
|
+
# @!attribute [r] title
|
|
12
|
+
# @return [String, nil] optional human-readable name for display purposes
|
|
13
|
+
# @!attribute [r] mime_type
|
|
14
|
+
# @return [String, nil] optional MIME type
|
|
15
|
+
# @!attribute [r] text
|
|
16
|
+
# @return [String, nil] text content (mutually exclusive with blob)
|
|
17
|
+
# @!attribute [r] blob
|
|
18
|
+
# @return [String, nil] base64-encoded binary content (mutually exclusive with text)
|
|
19
|
+
# @!attribute [r] annotations
|
|
20
|
+
# @return [Hash, nil] optional annotations that provide hints to clients
|
|
21
|
+
attr_reader :uri, :name, :title, :mime_type, :text, :blob, :annotations
|
|
22
|
+
|
|
23
|
+
# Initialize resource content
|
|
24
|
+
# @param uri [String] unique identifier for the resource
|
|
25
|
+
# @param name [String] the name of the resource
|
|
26
|
+
# @param title [String, nil] optional human-readable name for display purposes
|
|
27
|
+
# @param mime_type [String, nil] optional MIME type
|
|
28
|
+
# @param text [String, nil] text content (mutually exclusive with blob)
|
|
29
|
+
# @param blob [String, nil] base64-encoded binary content (mutually exclusive with text)
|
|
30
|
+
# @param annotations [Hash, nil] optional annotations that provide hints to clients
|
|
31
|
+
def initialize(uri:, name:, title: nil, mime_type: nil, text: nil, blob: nil, annotations: nil)
|
|
32
|
+
raise ArgumentError, 'ResourceContent cannot have both text and blob' if text && blob
|
|
33
|
+
raise ArgumentError, 'ResourceContent must have either text or blob' if !text && !blob
|
|
34
|
+
|
|
35
|
+
@uri = uri
|
|
36
|
+
@name = name
|
|
37
|
+
@title = title
|
|
38
|
+
@mime_type = mime_type
|
|
39
|
+
@text = text
|
|
40
|
+
@blob = blob
|
|
41
|
+
@annotations = annotations
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Create a ResourceContent instance from JSON data
|
|
45
|
+
# @param data [Hash] JSON data from MCP server
|
|
46
|
+
# @return [MCPClient::ResourceContent] resource content instance
|
|
47
|
+
def self.from_json(data)
|
|
48
|
+
new(
|
|
49
|
+
uri: data['uri'],
|
|
50
|
+
name: data['name'],
|
|
51
|
+
title: data['title'],
|
|
52
|
+
mime_type: data['mimeType'],
|
|
53
|
+
text: data['text'],
|
|
54
|
+
blob: data['blob'],
|
|
55
|
+
annotations: data['annotations']
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if content is text
|
|
60
|
+
# @return [Boolean] true if content is text
|
|
61
|
+
def text?
|
|
62
|
+
!@text.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if content is binary
|
|
66
|
+
# @return [Boolean] true if content is binary
|
|
67
|
+
def binary?
|
|
68
|
+
!@blob.nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get the content (text or decoded blob)
|
|
72
|
+
# @return [String] the content
|
|
73
|
+
def content
|
|
74
|
+
return @text if text?
|
|
75
|
+
|
|
76
|
+
require 'base64'
|
|
77
|
+
Base64.decode64(@blob)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Representation of an MCP resource template
|
|
5
|
+
# Resource templates allow servers to expose parameterized resources using URI templates
|
|
6
|
+
class ResourceTemplate
|
|
7
|
+
# @!attribute [r] uri_template
|
|
8
|
+
# @return [String] URI template following RFC 6570
|
|
9
|
+
# @!attribute [r] name
|
|
10
|
+
# @return [String] the name of the resource template
|
|
11
|
+
# @!attribute [r] title
|
|
12
|
+
# @return [String, nil] optional human-readable name for display purposes
|
|
13
|
+
# @!attribute [r] description
|
|
14
|
+
# @return [String, nil] optional description
|
|
15
|
+
# @!attribute [r] mime_type
|
|
16
|
+
# @return [String, nil] optional MIME type for resources created from this template
|
|
17
|
+
# @!attribute [r] annotations
|
|
18
|
+
# @return [Hash, nil] optional annotations that provide hints to clients
|
|
19
|
+
# @!attribute [r] server
|
|
20
|
+
# @return [MCPClient::ServerBase, nil] the server this resource template belongs to
|
|
21
|
+
attr_reader :uri_template, :name, :title, :description, :mime_type, :annotations, :server
|
|
22
|
+
|
|
23
|
+
# Initialize a new resource template
|
|
24
|
+
# @param uri_template [String] URI template following RFC 6570
|
|
25
|
+
# @param name [String] the name of the resource template
|
|
26
|
+
# @param title [String, nil] optional human-readable name for display purposes
|
|
27
|
+
# @param description [String, nil] optional description
|
|
28
|
+
# @param mime_type [String, nil] optional MIME type
|
|
29
|
+
# @param annotations [Hash, nil] optional annotations that provide hints to clients
|
|
30
|
+
# @param server [MCPClient::ServerBase, nil] the server this resource template belongs to
|
|
31
|
+
def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil, annotations: nil, server: nil)
|
|
32
|
+
@uri_template = uri_template
|
|
33
|
+
@name = name
|
|
34
|
+
@title = title
|
|
35
|
+
@description = description
|
|
36
|
+
@mime_type = mime_type
|
|
37
|
+
@annotations = annotations
|
|
38
|
+
@server = server
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Create a ResourceTemplate instance from JSON data
|
|
42
|
+
# @param data [Hash] JSON data from MCP server
|
|
43
|
+
# @param server [MCPClient::ServerBase, nil] the server this resource template belongs to
|
|
44
|
+
# @return [MCPClient::ResourceTemplate] resource template instance
|
|
45
|
+
def self.from_json(data, server: nil)
|
|
46
|
+
new(
|
|
47
|
+
uri_template: data['uriTemplate'],
|
|
48
|
+
name: data['name'],
|
|
49
|
+
title: data['title'],
|
|
50
|
+
description: data['description'],
|
|
51
|
+
mime_type: data['mimeType'],
|
|
52
|
+
annotations: data['annotations'],
|
|
53
|
+
server: server
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -48,18 +48,46 @@ module MCPClient
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# List all resources available from the MCP server
|
|
51
|
-
# @
|
|
52
|
-
|
|
51
|
+
# @param cursor [String, nil] optional cursor for pagination
|
|
52
|
+
# @return [Hash] result containing resources array and optional nextCursor
|
|
53
|
+
def list_resources(cursor: nil)
|
|
53
54
|
raise NotImplementedError, 'Subclasses must implement list_resources'
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
# Read a resource by its URI
|
|
57
58
|
# @param uri [String] the URI of the resource to read
|
|
58
|
-
# @return [
|
|
59
|
+
# @return [Array<MCPClient::ResourceContent>] array of resource contents
|
|
59
60
|
def read_resource(uri)
|
|
60
61
|
raise NotImplementedError, 'Subclasses must implement read_resource'
|
|
61
62
|
end
|
|
62
63
|
|
|
64
|
+
# List all resource templates available from the MCP server
|
|
65
|
+
# @param cursor [String, nil] optional cursor for pagination
|
|
66
|
+
# @return [Hash] result containing resourceTemplates array and optional nextCursor
|
|
67
|
+
def list_resource_templates(cursor: nil)
|
|
68
|
+
raise NotImplementedError, 'Subclasses must implement list_resource_templates'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Subscribe to resource updates
|
|
72
|
+
# @param uri [String] the URI of the resource to subscribe to
|
|
73
|
+
# @return [Boolean] true if subscription successful
|
|
74
|
+
def subscribe_resource(uri)
|
|
75
|
+
raise NotImplementedError, 'Subclasses must implement subscribe_resource'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Unsubscribe from resource updates
|
|
79
|
+
# @param uri [String] the URI of the resource to unsubscribe from
|
|
80
|
+
# @return [Boolean] true if unsubscription successful
|
|
81
|
+
def unsubscribe_resource(uri)
|
|
82
|
+
raise NotImplementedError, 'Subclasses must implement unsubscribe_resource'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get server capabilities
|
|
86
|
+
# @return [Hash, nil] server capabilities
|
|
87
|
+
def capabilities
|
|
88
|
+
raise NotImplementedError, 'Subclasses must implement capabilities'
|
|
89
|
+
end
|
|
90
|
+
|
|
63
91
|
# Clean up the server connection
|
|
64
92
|
def cleanup
|
|
65
93
|
raise NotImplementedError, 'Subclasses must implement cleanup'
|
|
@@ -76,7 +76,8 @@ module MCPClient
|
|
|
76
76
|
retries: config[:retries] || 3,
|
|
77
77
|
retry_backoff: config[:retry_backoff] || 1,
|
|
78
78
|
name: config[:name],
|
|
79
|
-
logger: logger
|
|
79
|
+
logger: logger,
|
|
80
|
+
oauth_provider: config[:oauth_provider]
|
|
80
81
|
)
|
|
81
82
|
end
|
|
82
83
|
|
|
@@ -95,7 +96,8 @@ module MCPClient
|
|
|
95
96
|
retries: config[:retries] || 3,
|
|
96
97
|
retry_backoff: config[:retry_backoff] || 1,
|
|
97
98
|
name: config[:name],
|
|
98
|
-
logger: logger
|
|
99
|
+
logger: logger,
|
|
100
|
+
oauth_provider: config[:oauth_provider]
|
|
99
101
|
)
|
|
100
102
|
end
|
|
101
103
|
|
|
@@ -12,6 +12,14 @@ module MCPClient
|
|
|
12
12
|
# Implementation of MCP server that communicates via HTTP requests/responses
|
|
13
13
|
# Useful for communicating with MCP servers that support HTTP-based transport
|
|
14
14
|
# without Server-Sent Events streaming
|
|
15
|
+
#
|
|
16
|
+
# @note Elicitation Support (MCP 2025-06-18)
|
|
17
|
+
# This transport does NOT support server-initiated elicitation requests.
|
|
18
|
+
# The HTTP transport uses a pure request-response architecture where only the client
|
|
19
|
+
# can initiate requests. For elicitation support, use one of these transports instead:
|
|
20
|
+
# - ServerStdio: Full bidirectional JSON-RPC over stdin/stdout
|
|
21
|
+
# - ServerSSE: Server requests via SSE stream, client responses via HTTP POST
|
|
22
|
+
# - ServerStreamableHTTP: Server requests via SSE-formatted responses, client responses via HTTP POST
|
|
15
23
|
class ServerHTTP < ServerBase
|
|
16
24
|
require_relative 'server_http/json_rpc_transport'
|
|
17
25
|
|
|
@@ -216,6 +224,148 @@ module MCPClient
|
|
|
216
224
|
end
|
|
217
225
|
end
|
|
218
226
|
|
|
227
|
+
# List all prompts available from the MCP server
|
|
228
|
+
# @return [Array<MCPClient::Prompt>] list of available prompts
|
|
229
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
230
|
+
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
231
|
+
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
|
|
232
|
+
def list_prompts
|
|
233
|
+
@mutex.synchronize do
|
|
234
|
+
return @prompts if @prompts
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
begin
|
|
238
|
+
ensure_connected
|
|
239
|
+
|
|
240
|
+
prompts_data = rpc_request('prompts/list')
|
|
241
|
+
prompts = prompts_data['prompts'] || []
|
|
242
|
+
|
|
243
|
+
@mutex.synchronize do
|
|
244
|
+
@prompts = prompts.map do |prompt_data|
|
|
245
|
+
MCPClient::Prompt.from_json(prompt_data, server: self)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
@mutex.synchronize { @prompts }
|
|
250
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
251
|
+
raise
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Get a prompt with the given parameters
|
|
258
|
+
# @param prompt_name [String] the name of the prompt to get
|
|
259
|
+
# @param parameters [Hash] the parameters to pass to the prompt
|
|
260
|
+
# @return [Object] the result of the prompt interpolation
|
|
261
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
262
|
+
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
263
|
+
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
|
|
264
|
+
def get_prompt(prompt_name, parameters)
|
|
265
|
+
rpc_request('prompts/get', {
|
|
266
|
+
name: prompt_name,
|
|
267
|
+
arguments: parameters
|
|
268
|
+
})
|
|
269
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
270
|
+
raise
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
raise MCPClient::Errors::PromptGetError, "Error getting prompt '#{prompt_name}': #{e.message}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# List all resources available from the MCP server
|
|
276
|
+
# @param cursor [String, nil] optional cursor for pagination
|
|
277
|
+
# @return [Hash] result containing resources array and optional nextCursor
|
|
278
|
+
# @raise [MCPClient::Errors::ResourceReadError] if resources list retrieval fails
|
|
279
|
+
def list_resources(cursor: nil)
|
|
280
|
+
@mutex.synchronize do
|
|
281
|
+
return @resources_result if @resources_result && !cursor
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
begin
|
|
285
|
+
ensure_connected
|
|
286
|
+
|
|
287
|
+
params = {}
|
|
288
|
+
params['cursor'] = cursor if cursor
|
|
289
|
+
result = rpc_request('resources/list', params)
|
|
290
|
+
|
|
291
|
+
resources = (result['resources'] || []).map do |resource_data|
|
|
292
|
+
MCPClient::Resource.from_json(resource_data, server: self)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
resources_result = { 'resources' => resources, 'nextCursor' => result['nextCursor'] }
|
|
296
|
+
|
|
297
|
+
@mutex.synchronize do
|
|
298
|
+
@resources_result = resources_result unless cursor
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
resources_result
|
|
302
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
303
|
+
raise
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resources: #{e.message}"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Read a resource by its URI
|
|
310
|
+
# @param uri [String] the URI of the resource to read
|
|
311
|
+
# @return [Array<MCPClient::ResourceContent>] array of resource contents
|
|
312
|
+
# @raise [MCPClient::Errors::ResourceReadError] if resource reading fails
|
|
313
|
+
def read_resource(uri)
|
|
314
|
+
result = rpc_request('resources/read', { uri: uri })
|
|
315
|
+
contents = result['contents'] || []
|
|
316
|
+
contents.map { |content| MCPClient::ResourceContent.from_json(content) }
|
|
317
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
318
|
+
raise
|
|
319
|
+
rescue StandardError => e
|
|
320
|
+
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# List all resource templates available from the MCP server
|
|
324
|
+
# @param cursor [String, nil] optional cursor for pagination
|
|
325
|
+
# @return [Hash] result containing resourceTemplates array and optional nextCursor
|
|
326
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during resource template listing
|
|
327
|
+
def list_resource_templates(cursor: nil)
|
|
328
|
+
params = {}
|
|
329
|
+
params['cursor'] = cursor if cursor
|
|
330
|
+
result = rpc_request('resources/templates/list', params)
|
|
331
|
+
|
|
332
|
+
templates = (result['resourceTemplates'] || []).map do |template_data|
|
|
333
|
+
MCPClient::ResourceTemplate.from_json(template_data, server: self)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
{ 'resourceTemplates' => templates, 'nextCursor' => result['nextCursor'] }
|
|
337
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
338
|
+
raise
|
|
339
|
+
rescue StandardError => e
|
|
340
|
+
raise MCPClient::Errors::ResourceReadError, "Error listing resource templates: #{e.message}"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Subscribe to resource updates
|
|
344
|
+
# @param uri [String] the URI of the resource to subscribe to
|
|
345
|
+
# @return [Boolean] true if subscription successful
|
|
346
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
|
|
347
|
+
def subscribe_resource(uri)
|
|
348
|
+
rpc_request('resources/subscribe', { uri: uri })
|
|
349
|
+
true
|
|
350
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
351
|
+
raise
|
|
352
|
+
rescue StandardError => e
|
|
353
|
+
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Unsubscribe from resource updates
|
|
357
|
+
# @param uri [String] the URI of the resource to unsubscribe from
|
|
358
|
+
# @return [Boolean] true if unsubscription successful
|
|
359
|
+
# @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
|
|
360
|
+
def unsubscribe_resource(uri)
|
|
361
|
+
rpc_request('resources/unsubscribe', { uri: uri })
|
|
362
|
+
true
|
|
363
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
364
|
+
raise
|
|
365
|
+
rescue StandardError => e
|
|
366
|
+
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
367
|
+
end
|
|
368
|
+
|
|
219
369
|
# Stream tool call (default implementation returns single-value stream)
|
|
220
370
|
# @param tool_name [String] the name of the tool to call
|
|
221
371
|
# @param parameters [Hash] the parameters to pass to the tool
|
|
@@ -31,6 +31,7 @@ module MCPClient
|
|
|
31
31
|
data = JSON.parse(event[:data])
|
|
32
32
|
|
|
33
33
|
return if process_error_in_message(data)
|
|
34
|
+
return if process_server_request?(data)
|
|
34
35
|
return if process_notification?(data)
|
|
35
36
|
|
|
36
37
|
process_response?(data)
|
|
@@ -58,6 +59,16 @@ module MCPClient
|
|
|
58
59
|
true
|
|
59
60
|
end
|
|
60
61
|
|
|
62
|
+
# Process a JSON-RPC request from server (has both id AND method)
|
|
63
|
+
# @param data [Hash] the parsed JSON payload
|
|
64
|
+
# @return [Boolean] true if we saw & handled a server request
|
|
65
|
+
def process_server_request?(data)
|
|
66
|
+
return false unless data['method'] && data.key?('id')
|
|
67
|
+
|
|
68
|
+
handle_server_request(data)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
61
72
|
# Process a JSON-RPC notification (no id => notification)
|
|
62
73
|
# @param data [Hash] the parsed JSON payload
|
|
63
74
|
# @return [Boolean] true if we saw & handled a notification
|