Class: Encom::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/encom/client.rb

Defined Under Namespace

Classes: ConnectionError, ProtocolVersionError, RequestTimeoutError, ToolError

Constant Summary collapse

LATEST_PROTOCOL_VERSION =
'2024-11-05'
SUPPORTED_PROTOCOL_VERSIONS =
[
  LATEST_PROTOCOL_VERSION
  # Add more supported versions as they're developed
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, version:, capabilities:) ⇒ Client

Returns a new instance of Client.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/encom/client.rb', line 21

def initialize(name:, version:, capabilities:)
  @name = name
  @version = version
  @capabilities = capabilities
  @message_id = 0
  @responses = []
  @tool_responses = {}
  @pending_requests = {}
  @response_mutex = Mutex.new
  @response_condition = ConditionVariable.new
  @initialized = false
  @closing = false
  @error_handlers = []
  @first_error_reported = false # Flag to track if we've already reported an error
end

Instance Attribute Details

#initializedObject (readonly)

Returns the value of attribute initialized.



18
19
20
# File 'lib/encom/client.rb', line 18

def initialized
  @initialized
end

#nameObject (readonly)

Returns the value of attribute name.



18
19
20
# File 'lib/encom/client.rb', line 18

def name
  @name
end

#protocol_versionObject (readonly)

Returns the value of attribute protocol_version.



18
19
20
# File 'lib/encom/client.rb', line 18

def protocol_version
  @protocol_version
end

#responsesObject (readonly)

Returns the value of attribute responses.



18
19
20
# File 'lib/encom/client.rb', line 18

def responses
  @responses
end

#server_capabilitiesObject (readonly)

Returns the value of attribute server_capabilities.



18
19
20
# File 'lib/encom/client.rb', line 18

def server_capabilities
  @server_capabilities
end

#server_infoObject (readonly)

Returns the value of attribute server_info.



18
19
20
# File 'lib/encom/client.rb', line 18

def server_info
  @server_info
end

#tool_responsesObject (readonly)

Returns the value of attribute tool_responses.



18
19
20
# File 'lib/encom/client.rb', line 18

def tool_responses
  @tool_responses
end

#versionObject (readonly)

Returns the value of attribute version.



18
19
20
# File 'lib/encom/client.rb', line 18

def version
  @version
end

Instance Method Details

#call_tool(name:, arguments:, timeout: 5) ⇒ Hash

Call a tool on the server

Parameters:

  • name (String)

    The name of the tool to call

  • arguments (Hash)

    The arguments to pass to the tool

  • timeout (Numeric) (defaults to: 5)

    The timeout in seconds

Returns:

  • (Hash)

    The tool result containing content array

Raises:



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/encom/client.rb', line 246

def call_tool(name:, arguments:, timeout: 5)
  id = request(
    {
      method: 'tools/call',
      params: {
        name: name,
        arguments: arguments
      }
    }
  )

  # Wait for the response
  response = wait_for_response(id, timeout)

  if response[:error]
    error_msg = "Tool error: #{response[:error][:message]} (#{response[:error][:code]})"
    raise ToolError, error_msg
  end

  # Return the result content
  response[:result]
end

#closeObject



296
297
298
299
300
301
302
303
304
305
306
# File 'lib/encom/client.rb', line 296

def close
  return if @closing

  @closing = true
  puts 'Closing'

  return unless @transport

  @transport.close
  @transport = nil
end

#connect(transport) ⇒ Object



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/encom/client.rb', line 43

def connect(transport)
  @transport = transport

  @transport
    .on_close { close }
    .on_data { |data| handle_response(data) }
    .on_error { |error| handle_transport_error(error) }
    .start

  # Send initialize request
  request(
    {
      method: 'initialize',
      params: {
        protocolVersion: LATEST_PROTOCOL_VERSION,
        capabilities: @capabilities,
        clientInfo: {
          name: @name,
          version: @version
        }
      }
    }
  )
rescue JSON::ParserError => e
  # This might be a protocol version error or other startup error
  trigger_error(ConnectionError.new("Error parsing initial response: #{e.message}"))
end

#get_tool_result(request_id) ⇒ Hash?

Get the result of a tool call

Parameters:

  • request_id (Integer)

    The ID of the call_tool request

Returns:

  • (Hash, nil)

    The tool result or nil if the response isn’t available



284
285
286
287
288
289
290
291
292
293
294
# File 'lib/encom/client.rb', line 284

def get_tool_result(request_id)
  response = @tool_responses[request_id]
  return nil unless response

  if response[:error]
    error_msg = "Tool error: #{response[:error][:message]} (#{response[:error][:code]})"
    raise ToolError, error_msg
  end

  response[:result]
end

#get_tools(request_id) ⇒ Array<Hash>?

Get the list of tools from a previous list_tools request

Parameters:

  • request_id (Integer)

    The ID of the list_tools request

Returns:

  • (Array<Hash>, nil)

    The list of tools or nil if the response isn’t available



273
274
275
276
277
278
# File 'lib/encom/client.rb', line 273

def get_tools(request_id)
  response = @tool_responses[request_id]
  return nil unless response && response[:result]

  response[:result][:tools]
end

#handle_error(response) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/encom/client.rb', line 135

def handle_error(response)
  # Don't process errors if we're already closing
  return if @closing

  # Check if this is an error response to an initialize request
  if @pending_requests[response[:id]] == 'initialize' &&
     response[:error][:code] == Encom::ErrorCodes::PROTOCOL_ERROR
    error = ProtocolVersionError.new("Unsupported protocol version: #{response[:error][:message]}")
    close
    trigger_error(error)
    return
  end

  error_msg = "Error from server: #{response[:error][:message]} (#{response[:error][:code]})"
  trigger_error(ConnectionError.new(error_msg))
end

#handle_initialize_result(response) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/encom/client.rb', line 111

def handle_initialize_result(response)
  @server_info = response[:result][:serverInfo]
  @server_capabilities = response[:result][:capabilities]
  @protocol_version = response[:result][:protocolVersion]

  unless SUPPORTED_PROTOCOL_VERSIONS.include?(@protocol_version)
    error_message = "Unsupported protocol version: #{@protocol_version}. " \
                    "This client supports: #{SUPPORTED_PROTOCOL_VERSIONS.join(', ')}"
    error = ProtocolVersionError.new(error_message)
    close
    trigger_error(error)
    return
  end

  @initialized = true

  notification(
    {
      method: 'initialized',
      params: {}
    }
  )
end

#handle_response(data) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/encom/client.rb', line 75

def handle_response(data)
  data = data.strip if data.is_a?(String)
  parsed_response = JSON.parse(data, symbolize_names: true)

  @responses << parsed_response

  # Check for protocol errors immediately, even without an ID
  if parsed_response[:error] &&
     parsed_response[:error][:code] == Encom::ErrorCodes::PROTOCOL_ERROR &&
     parsed_response[:error][:message].include?('Unsupported protocol version')
    # Only trigger a protocol error if we haven't already closed the connection
    unless @closing
      error = ProtocolVersionError.new(parsed_response[:error][:message])
      close
      trigger_error(error)
    end
    return
  end

  if parsed_response[:id]
    @response_mutex.synchronize do
      @tool_responses[parsed_response[:id]] = parsed_response
      @response_condition.broadcast # Signal threads waiting for this response
    end

    if parsed_response[:result]
      handle_initialize_result(parsed_response) if @pending_requests[parsed_response[:id]] == 'initialize'
    elsif parsed_response[:error]
      handle_error(parsed_response)
    end
  end
rescue JSON::ParserError => e
  error_msg = "Error parsing response: #{e.message}, Raw response: #{data.inspect}"
  trigger_error(ConnectionError.new(error_msg))
end

#handle_transport_error(error) ⇒ Object



71
72
73
# File 'lib/encom/client.rb', line 71

def handle_transport_error(error)
  trigger_error(ConnectionError.new("Transport error: #{error.message}"))
end

#list_tools(params = nil, timeout = 5) ⇒ Array<Hash>

List available tools from the server

Parameters:

  • params (Hash, nil) (defaults to: nil)

    Optional parameters for the list_tools request

  • timeout (Numeric) (defaults to: 5)

    The timeout in seconds

Returns:

  • (Array<Hash>)

    The list of tools

Raises:



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/encom/client.rb', line 216

def list_tools(params = nil, timeout = 5)
  request_data = {
    method: 'tools/list'
  }

  request_data[:params] = params if params

  id = request(request_data)

  # Wait for the response
  response = wait_for_response(id, timeout)

  if response[:error]
    error_msg = "Error from server: #{response[:error][:message]} (#{response[:error][:code]})"
    raise ConnectionError, error_msg
  end

  # Return the tools array
  response[:result][:tools]
end

#notification(notification_data) ⇒ Object



183
184
185
186
187
188
189
# File 'lib/encom/client.rb', line 183

def notification(notification_data)
  @transport.send(
    JSON.generate({
      jsonrpc: '2.0'
    }.merge(notification_data))
  )
end

#on_error(&block) ⇒ Object

Register a callback for error handling



38
39
40
41
# File 'lib/encom/client.rb', line 38

def on_error(&block)
  @error_handlers << block
  self
end

#request(request_data) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/encom/client.rb', line 167

def request(request_data)
  @message_id += 1
  id = @message_id

  @pending_requests[id] = request_data[:method]

  @transport.send(
    JSON.generate({
      jsonrpc: '2.0',
      id: id
    }.merge(request_data))
  )

  id
end

#trigger_error(error) ⇒ Object



152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/encom/client.rb', line 152

def trigger_error(error)
  # Only report the first error
  return if @first_error_reported

  @first_error_reported = true

  if @error_handlers.empty?
    # TODO: I'd love to re-raise this to the user but this ends up run
    # in a background thread right now due to how we've implement the stdio transport
    puts "MCP Client Error: #{error.message}"
  else
    @error_handlers.each { |handler| handler.call(error) }
  end
end

#wait_for_response(id, timeout = 5) ⇒ Hash

Wait for a response with a specific ID, with timeout

Parameters:

  • id (Integer)

    The ID of the request to wait for

  • timeout (Numeric) (defaults to: 5)

    The timeout in seconds

Returns:

  • (Hash)

    The response

Raises:



197
198
199
200
201
202
203
204
205
206
207
# File 'lib/encom/client.rb', line 197

def wait_for_response(id, timeout = 5)
  deadline = Time.now + timeout

  @response_mutex.synchronize do
    @response_condition.wait(@response_mutex, 0.1) while !@tool_responses.key?(id) && Time.now < deadline

    raise RequestTimeoutError, "Timeout waiting for response to request #{id}" unless @tool_responses.key?(id)

    @tool_responses[id]
  end
end