Class: Legate::Mcp::Client

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

Constant Summary collapse

DEFAULT_RESPONSE_TIMEOUT =
30
PROCESS_START_TIMEOUT =
Connection::Stdio::PROCESS_START_TIMEOUT
CLIENT_PROTOCOL_VERSION =

— Define the protocol version Legate Client supports —

'2024-11-05'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(connection_params) ⇒ Client

… (initialize remains the same) …



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/legate/mcp/client.rb', line 21

def initialize(connection_params)
  @connection_params = connection_params
  @connection = nil
  @server_capabilities = nil
  @connected = false
  @pending_requests = {}
  @lock = Mutex.new
  @last_error = nil

  # Validate connection params based on type
  case @connection_params[:type]
  when :stdio
    raise ArgumentError, 'Missing :command for :stdio connection' unless @connection_params[:command]
  when :sse
    raise ArgumentError, 'Missing :url for :sse connection' unless @connection_params[:url]
  else
    raise ArgumentError, "Unsupported connection type: #{@connection_params[:type]}"
  end
end

Instance Attribute Details

#connection_paramsObject (readonly)




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

def connection_params
  @connection_params
end

#last_errorObject (readonly)




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

def last_error
  @last_error
end

#server_capabilitiesObject (readonly)




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

def server_capabilities
  @server_capabilities
end

Instance Method Details

#call_tool(name, arguments) ⇒ Any

Calls a tool on the MCP server.

Parameters:

  • name (String)

    The name of the tool to call.

  • arguments (Hash)

    The arguments for the tool.

Returns:

  • (Any)

    The result payload from the tool execution.

Raises:



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/legate/mcp/client.rb', line 205

def call_tool(name, arguments)
  raise ConnectionError, 'Not connected' unless connected?
  raise ArgumentError, 'Arguments must be a Hash' unless arguments.is_a?(Hash)

  Legate.logger.debug("Calling MCP tool '#{name}' with args: #{arguments.inspect}")
  id = @connection.next_request_id
  request = {
    jsonrpc: '2.0',
    id: id,
    method: 'tools/call',
    params: { name: name, arguments: arguments }
  }

  response = send_request_and_wait(request)

  if response&.key?(:result)
    Legate.logger.debug("MCP tool '#{name}' call successful. Result: #{response[:result].inspect}")
    response[:result]
  elsif response&.key?(:error)
    err = response[:error]
    @last_error = "MCP tool '#{name}' call failed: #{err[:message]} (Code: #{err[:code]})"
    Legate.logger.error("#{@last_error} Data: #{err[:data].inspect}")
    raise RemoteToolError.new(err[:message], err[:code], err[:data])
  else
    @last_error = "MCP tool '#{name}' call failed: Invalid or missing response. #{response ? "Resp: #{response.inspect}" : 'Connection likely closed.'}"
    raise ProtocolError, @last_error
  end
end

#connectObject



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
70
71
72
73
74
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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/legate/mcp/client.rb', line 45

def connect
  return true if connected?

  error_occurred = nil

  @lock.synchronize do
    return true if @connected # Double check

    Legate.logger.info('MCP Client connecting...')
    @last_error = nil
    @connection = nil
    @connected = false

    begin
      case @connection_params[:type]
      when :stdio
        @connection = Connection::Stdio.new(
          command: @connection_params[:command],
          args: @connection_params[:args] || []
        )
      when :sse
        # require_relative 'connection/sse' # Ensure loaded if not globally required
        @connection = Connection::Sse.new(url: @connection_params[:url])
      else
        raise ConnectionError, "Cannot connect: Unsupported connection type: #{@connection_params[:type]}"
      end

      @connection.connect
      @connected = true # Assume connected for handshake

      Legate.logger.info('Performing MCP initialize handshake...')
      id = @connection.next_request_id
      # --- MODIFICATION: Add protocolVersion to params ---
      request = {
        jsonrpc: '2.0', id: id, method: 'initialize',
        params: {
          protocolVersion: CLIENT_PROTOCOL_VERSION,
          clientInfo: { name: 'legate-client', version: Legate::VERSION },
          capabilities: {} # Keep capabilities empty for now
        }
      }
      # --- End Modification ---
      Legate.logger.info("Initialize Request: #{request.inspect}") # Log modified request

      response = send_request_and_wait(request, timeout: PROCESS_START_TIMEOUT)

      unless response && response[:result]
        error_msg = 'MCP Initialize failed: No response or missing result.'
        if response&.dig(:error)
          err = response[:error]
          error_msg += " Server Error: #{err[:message]} (Code: #{err[:code]})"
        elsif !response
          error_msg += ' Connection likely closed or timed out.'
        else
          error_msg += " Response: #{response.inspect}"
        end
        @last_error = error_msg
        raise ConnectionError, @last_error # Raise to be caught below
      end

      # --- Optional: Validate Server Protocol Version ---
      server_protocol_version = response.dig(:result, :protocolVersion)
      if server_protocol_version && server_protocol_version != CLIENT_PROTOCOL_VERSION
        Legate.logger.warn("MCP Protocol version mismatch. Client: #{CLIENT_PROTOCOL_VERSION}, Server: #{server_protocol_version}")
        # Decide if this is a critical error - for now, just log a warning
      end
      # --- End Protocol Version Check ---

      @server_capabilities = response.dig(:result, :capabilities) || {}
      Legate.logger.info("MCP Handshake successful. Server capabilities: #{@server_capabilities.inspect}")
      Legate.logger.info('MCP Client connected successfully.')
    rescue ConnectionError => e
      Legate.logger.error("MCP Client connection/handshake failed: #{e.message}")
      error_occurred = e
      @connected = false
    rescue StandardError => e
      @last_error = "MCP Client unexpected error during connect: #{e.class} - #{e.message}"
      Legate.logger.error("#{@last_error}\n#{e.backtrace.join("\n")}")
      error_occurred = ConnectionError.new(@last_error)
      @connected = false
    end
  end # Lock released

  if error_occurred
    disconnect
    raise error_occurred
  end

  true
end

#connected?Boolean

Returns:

  • (Boolean)


41
42
43
# File 'lib/legate/mcp/client.rb', line 41

def connected?
  @connected && @connection&.connected?
end

#disconnectObject

Disconnects from the MCP server.



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/legate/mcp/client.rb', line 137

def disconnect
  @lock.synchronize do
    return unless @connected || @connection # Check if there's anything to disconnect

    Legate.logger.info('MCP Client disconnecting...')
    @connected = false
    @server_capabilities = nil
    @pending_requests.clear

    begin
      @connection&.disconnect
    rescue StandardError => e
      Legate.logger.error("MCP Client error during disconnect: #{e.message}")
    end

    @connection = nil
    Legate.logger.info('MCP Client disconnected.')
  end
ensure
  # Ensure state is updated even if disconnect fails
  @connected = false
  @connection = nil
end

#list_toolsArray<Hash>

Lists available tools from the MCP server.

Returns:

  • (Array<Hash>)

    List of MCP tool schemas.

Raises:



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/legate/mcp/client.rb', line 165

def list_tools
  raise ConnectionError, 'Not connected' unless connected?

  Legate.logger.debug('Requesting tools list from MCP server...')
  id = @connection.next_request_id
  request = {
    jsonrpc: '2.0',
    id: id,
    method: 'tools/list',
    params: {}
  }

  response = send_request_and_wait(request)

  if response&.key?(:result)
    tools = response.dig(:result, :tools)
    unless tools.is_a?(Array)
      @last_error = "MCP tools/list invalid response: 'result.tools' is not an Array. Response: #{response.inspect}"
      raise ProtocolError, @last_error
    end
    Legate.logger.debug("Received #{tools.count} tools from MCP server.")
    tools
  elsif response&.key?(:error)
    err = response[:error]
    @last_error = "MCP tools/list failed: #{err[:message]} (Code: #{err[:code]})"
    Legate.logger.error(@last_error)
    raise RemoteToolError.new(@last_error, err[:code], err[:data])
  else
    @last_error = "MCP tools/list failed: Invalid or missing response. #{response ? "Resp: #{response.inspect}" : 'Connection likely closed.'}"
    raise ProtocolError, @last_error
  end
end

#read_notification(timeout = 0.1) ⇒ Hash?

Reads the next notification received from the server via the connection. This is primarily useful for SSE connections.

Parameters:

  • timeout (Numeric) (defaults to: 0.1)

    Seconds to wait (default 0.1).

Returns:

  • (Hash, nil)

    Notification hash or nil.



238
239
240
241
242
243
244
245
246
247
248
# File 'lib/legate/mcp/client.rb', line 238

def read_notification(timeout = 0.1)
  return nil unless connected?

  # Delegate to connection-specific method if it exists, otherwise return nil
  if @connection.respond_to?(:read_notification)
    @connection.read_notification(timeout)
  else
    Legate.logger.debug("Connection type #{@connection_params[:type]} does not support read_notification.")
    nil
  end
end