Skip to content

Commit 515fc90

Browse files
committed
Phase 3: Implement Client - Client class, Retry mechanism, and Streaming support
1 parent 571cc95 commit 515fc90

File tree

4 files changed

+363
-2
lines changed

4 files changed

+363
-2
lines changed

lib/ruby_mcp/client.rb

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,104 @@
11
# frozen_string_literal: true
22

3+
require_relative 'client/client'
4+
require_relative 'client/retry'
5+
require_relative 'client/streaming'
6+
37
module MCP
8+
# Creates a new client instance with optional block configuration
9+
# @param options [Hash] Client options
10+
# @yield [Client::Client] The client instance
11+
# @return [Client::Client] The client instance
12+
def self.Client(options = {})
13+
client = Client::Client.new(options)
14+
15+
# Add additional capabilities
16+
client.extend(Client::Retry)
17+
client.extend(Client::Streaming)
18+
19+
yield client if block_given?
20+
client
21+
end
22+
423
# Main client class for MCP
5-
# This is a placeholder that will be implemented in Phase 3
624
class Client
25+
# Create a new client instance
26+
# @param options [Hash] Client options
27+
# @yield [self] The client instance
28+
# @return [self] The client instance
729
def initialize(options = {})
8-
raise NotImplementedError, 'Client implementation coming in Phase 3'
30+
@client = MCP.Client(options)
31+
32+
yield self if block_given?
33+
34+
self
35+
end
36+
37+
# Connect to the MCP server
38+
# @return [self] The client instance
39+
def connect
40+
@client.connect
41+
self
42+
end
43+
44+
# Disconnect from the MCP server
45+
# @return [self] The client instance
46+
def disconnect
47+
@client.disconnect
48+
self
49+
end
50+
51+
# Check if the client is connected
52+
# @return [Boolean] true if the client is connected
53+
def connected?
54+
@client.connected?
55+
end
56+
57+
# Get the list of available tools
58+
# @return [Array<Hash>] The list of available tools
59+
def list_tools
60+
@client.list_tools
61+
end
62+
63+
# Call a tool on the server
64+
# @param name [String] The name of the tool to call
65+
# @param arguments [Hash] The arguments to pass to the tool
66+
# @return [Array<Hash>] The tool result content
67+
def call_tool(name, arguments = {})
68+
@client.call_tool(name, arguments)
69+
end
70+
71+
# Stream a tool call on the server
72+
# @param name [String] The name of the tool to call
73+
# @param arguments [Hash] The arguments to pass to the tool
74+
# @yield [Hash] Each content chunk as it arrives
75+
# @return [Array<Hash>] The complete content when done
76+
def stream_tool(name, arguments = {}, &block)
77+
@client.stream_tool(name, arguments, &block)
78+
end
79+
80+
# Execute a block with retry
81+
# @param options [Hash] The retry options
82+
# @param retriable_errors [Array<Class>] The errors to retry on
83+
# @param retry_condition [Proc] Optional condition for retry
84+
# @yield The block to execute
85+
# @return [Object] The result of the block
86+
def with_retry(options = {}, retriable_errors = [StandardError], retry_condition = nil, &block)
87+
@client.with_retry(options, retriable_errors, retry_condition, &block)
88+
end
89+
90+
# Forward other methods to the client instance
91+
def method_missing(method, *args, &block)
92+
if @client.respond_to?(method)
93+
@client.send(method, *args, &block)
94+
else
95+
super
96+
end
97+
end
98+
99+
# Respond to check for forwarded methods
100+
def respond_to_missing?(method, include_private = false)
101+
@client.respond_to?(method, include_private) || super
9102
end
10103
end
11104
end

lib/ruby_mcp/client/client.rb

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# frozen_string_literal: true
2+
3+
require 'securerandom'
4+
5+
module MCP
6+
module Client
7+
# Main client class for MCP
8+
class Client
9+
attr_reader :id, :name, :options, :logger, :transport, :connection
10+
11+
def initialize(options = {})
12+
@id = SecureRandom.uuid
13+
@name = options[:name] || 'MCP Ruby Client'
14+
@options = options
15+
@logger = options[:logger] || MCP.logger
16+
@transport = initialize_transport(options)
17+
@connection = nil
18+
@event_handlers = {}
19+
@connected = false
20+
end
21+
22+
# Connect to the MCP server
23+
# @return [self] The client instance
24+
def connect
25+
return self if @connected
26+
27+
@logger.info("Connecting to MCP server as '#{@name}' (#{@id})")
28+
29+
# Initialize the transport
30+
transport_connection = @transport.connect
31+
32+
# Create the connection manager
33+
@connection = MCP::Protocol::Connection.new(transport_connection)
34+
35+
# Initialize the connection with the server
36+
initialize_connection
37+
38+
@connected = true
39+
self
40+
end
41+
42+
# Disconnect from the MCP server
43+
# @return [self] The client instance
44+
def disconnect
45+
return self unless @connected
46+
47+
@logger.info("Disconnecting from MCP server")
48+
49+
# Disconnect the transport
50+
@transport.disconnect
51+
52+
@connected = false
53+
@connection = nil
54+
55+
self
56+
end
57+
58+
# Check if the client is connected
59+
# @return [Boolean] true if the client is connected
60+
def connected?
61+
@connected && @transport.connected?
62+
end
63+
64+
# List available tools on the server
65+
# @return [Array<Hash>] The list of available tools
66+
def list_tools
67+
ensure_connected
68+
69+
request = MCP::Protocol::JsonRPC.request('tools/list')
70+
response = @connection.send_request(request)
71+
72+
if response[:error]
73+
raise MCP::Errors::ClientError, "Error listing tools: #{response[:error][:message]}"
74+
end
75+
76+
response[:result][:tools]
77+
end
78+
79+
# Call a tool on the server
80+
# @param name [String] The name of the tool to call
81+
# @param arguments [Hash] The arguments to pass to the tool
82+
# @return [Array<Hash>] The tool result content
83+
def call_tool(name, arguments = {})
84+
ensure_connected
85+
86+
request = MCP::Protocol::JsonRPC.request('tools/call', {
87+
name: name,
88+
arguments: arguments
89+
})
90+
91+
response = @connection.send_request(request)
92+
93+
if response[:error]
94+
raise MCP::Errors::ClientError, "Error calling tool: #{response[:error][:message]}"
95+
end
96+
97+
result = response[:result]
98+
99+
if result[:isError]
100+
error_message = get_error_message(result[:content])
101+
raise MCP::Errors::ToolError, "Tool error: #{error_message}"
102+
end
103+
104+
result[:content]
105+
end
106+
107+
# Register a handler for a specific event
108+
# @param event [Symbol] The event to handle
109+
# @param &block [Proc] The handler block
110+
def on_event(event, &block)
111+
@event_handlers[event] = block
112+
end
113+
114+
private
115+
116+
# Initialize the transport
117+
# @param options [Hash] The transport options
118+
# @return [MCP::Protocol::Transport::Base] The transport instance
119+
def initialize_transport(options)
120+
transport_options = options[:transport_options] || MCP.configuration.client_transport_options
121+
MCP::Protocol.create_transport(transport_options)
122+
end
123+
124+
# Initialize the connection with the server
125+
def initialize_connection
126+
@connection.initialize_connection(
127+
client_info: {
128+
name: @name,
129+
version: MCP::VERSION
130+
},
131+
protocol_version: MCP::PROTOCOL_VERSION,
132+
capabilities: client_capabilities
133+
)
134+
end
135+
136+
# Get the client capabilities
137+
# @return [Hash] The client capabilities
138+
def client_capabilities
139+
{
140+
# Set default capabilities here
141+
}
142+
end
143+
144+
# Get error message from content
145+
# @param content [Array<Hash>] The content array
146+
# @return [String] The error message
147+
def get_error_message(content)
148+
return "Unknown error" if content.nil? || content.empty?
149+
150+
text_content = content.find { |c| c[:type] == 'text' }
151+
text_content ? text_content[:text] : "Unknown error"
152+
end
153+
154+
# Ensure the client is connected
155+
# @raise [MCP::Errors::ConnectionError] If the client is not connected
156+
def ensure_connected
157+
unless connected?
158+
raise MCP::Errors::ConnectionError, "Client is not connected"
159+
end
160+
end
161+
end
162+
end
163+
end

lib/ruby_mcp/client/retry.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
module Client
5+
# Retry policy for client operations
6+
class RetryPolicy
7+
attr_reader :max_retries, :retry_interval, :max_retry_interval, :retry_multiplier
8+
9+
def initialize(options = {})
10+
@max_retries = options[:max_retries] || 3
11+
@retry_interval = options[:retry_interval] || 1.0
12+
@max_retry_interval = options[:max_retry_interval] || 30.0
13+
@retry_multiplier = options[:retry_multiplier] || 2.0
14+
end
15+
16+
# Execute a block with retry
17+
# @param retriable_errors [Array<Class>] The errors to retry on
18+
# @param retry_condition [Proc] Optional condition for retry
19+
# @yield The block to execute
20+
# @return [Object] The result of the block
21+
def with_retry(retriable_errors = [StandardError], retry_condition = nil)
22+
retries = 0
23+
interval = @retry_interval
24+
25+
begin
26+
yield
27+
rescue *retriable_errors => e
28+
retries += 1
29+
30+
if retries <= @max_retries && (retry_condition.nil? || retry_condition.call(e, retries))
31+
sleep interval
32+
interval = [interval * @retry_multiplier, @max_retry_interval].min
33+
retry
34+
end
35+
36+
raise
37+
end
38+
end
39+
end
40+
41+
# Retry mechanism for client operations
42+
module Retry
43+
# Execute a block with retry
44+
# @param options [Hash] The retry options
45+
# @param retriable_errors [Array<Class>] The errors to retry on
46+
# @param retry_condition [Proc] Optional condition for retry
47+
# @yield The block to execute
48+
# @return [Object] The result of the block
49+
def with_retry(options = {}, retriable_errors = [StandardError], retry_condition = nil, &block)
50+
policy = RetryPolicy.new(options)
51+
policy.with_retry(retriable_errors, retry_condition, &block)
52+
end
53+
end
54+
end
55+
end

lib/ruby_mcp/client/streaming.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
module Client
5+
# Streaming functionality for the client
6+
module Streaming
7+
# Stream a tool call on the server
8+
# @param name [String] The name of the tool to call
9+
# @param arguments [Hash] The arguments to pass to the tool
10+
# @yield [Hash] Each content chunk as it arrives
11+
# @return [Array<Hash>] The complete content when done
12+
def stream_tool(name, arguments = {})
13+
ensure_connected
14+
15+
result_content = []
16+
17+
# Create a streaming-ready request
18+
request = MCP::Protocol::JsonRPC.request('tools/call', {
19+
name: name,
20+
arguments: arguments,
21+
stream: true
22+
})
23+
24+
# Set up streaming handlers
25+
on_event(:content_chunk) do |chunk|
26+
result_content << chunk
27+
yield chunk if block_given?
28+
end
29+
30+
# Send the request
31+
response = @connection.send_request(request)
32+
33+
if response[:error]
34+
raise MCP::Errors::ClientError, "Error streaming tool: #{response[:error][:message]}"
35+
end
36+
37+
# Return the complete content
38+
result_content
39+
end
40+
41+
# Add streaming capabilities to a client
42+
# @param client [MCP::Client::Client] The client to add streaming to
43+
def self.included(client)
44+
unless client.included_modules.include?(self)
45+
client.include(self)
46+
end
47+
end
48+
end
49+
end
50+
end

0 commit comments

Comments
 (0)