lib/elelem/mcp_client.rb
# frozen_string_literal: true module Elelem class MCPClient attr_reader :tools, :resources def initialize(configuration, command = []) @configuration = configuration @stdin, @stdout, @stderr, @worker = Open3.popen3(*command, pgroup: true) # 1. Send initialize request send_request( method: "initialize", params: { protocolVersion: "2025-06-08", capabilities: { tools: {} }, clientInfo: { name: "Elelem", version: Elelem::VERSION } } ) # 2. Send initialized notification (optional for some MCP servers) send_notification(method: "notifications/initialized") # 3. Now we can request tools @tools = send_request(method: "tools/list")&.dig("tools") || [] @resources = send_request(method: "resources/list")&.dig("resources") || [] end def connected? return false unless @worker&.alive? return false unless @stdin && !@stdin.closed? return false unless @stdout && !@stdout.closed? begin Process.getpgid(@worker.pid) true rescue Errno::ESRCH false end end def call(name, arguments = {}) send_request( method: "tools/call", params: { name: name, arguments: arguments } ) end def shutdown return unless connected? configuration.logger.debug("Shutting down MCP client") [@stdin, @stdout, @stderr].each do |stream| stream&.close unless stream&.closed? end return unless @worker&.alive? begin Process.kill("TERM", @worker.pid) # Give it 2 seconds to terminate gracefully Timeout.timeout(2) { @worker.value } rescue Timeout::Error # Force kill if it doesn't respond begin Process.kill("KILL", @worker.pid) rescue StandardError nil end rescue Errno::ESRCH # Process already dead end end private attr_reader :stdin, :stdout, :stderr, :worker, :configuration def send_request(method:, params: {}) return {} unless connected? request = { jsonrpc: "2.0", id: Time.now.to_i, method: method } request[:params] = params unless params.empty? configuration.logger.debug(JSON.pretty_generate(request)) @stdin.puts(JSON.generate(request)) @stdin.flush response_line = @stdout.gets&.strip return {} if response_line.nil? || response_line.empty? response = JSON.parse(response_line) configuration.logger.debug(JSON.pretty_generate(response)) if response["error"] configuration.logger.error(response["error"]["message"]) { error: response["error"]["message"] } else response["result"] end end def send_notification(method:, params: {}) return unless connected? notification = { jsonrpc: "2.0", method: method } notification[:params] = params unless params.empty? configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}") @stdin.puts(JSON.generate(notification)) @stdin.flush response_line = @stdout.gets&.strip return {} if response_line.nil? || response_line.empty? response = JSON.parse(response_line) configuration.logger.debug(JSON.pretty_generate(response)) response end end end