Commit a253374

mo khan <mo@mokhan.ca>
2025-08-13 18:03:02
feat: try to load tools from serena mcp server
1 parent 0e2456f
lib/elelem/application.rb
@@ -35,10 +35,10 @@ module Elelem
           host: options[:host],
           model: options[:model],
           token: options[:token],
-          debug: options[:debug]
+          debug: options[:debug],
         )
-        say "Ollama Agent (#{configuration.model})", :green
-        say "Tools:\n  #{configuration.tools.banner}", :green
+        say "Agent (#{configuration.model})", :green
+        say "#{configuration.tools.banner}", :green
 
         agent = Agent.new(configuration)
         agent.repl
lib/elelem/configuration.rb
@@ -28,7 +28,7 @@ module Elelem
 
     def logger
       @logger ||= Logger.new(debug ? "elelem.log" : "/dev/null").tap do |logger|
-        logger.formatter = ->(_, _, _, message) { message.strip + "\n" }
+        logger.formatter = ->(_, _, _, message) { message.to_s.strip + "\n" }
       end
     end
 
lib/elelem/mcp_client.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require "json"
+require "open3"
+
+module Elelem
+  class MCPClient
+    attr_reader :tools
+
+    def initialize(configuration)
+      @configuration = configuration
+      @stdin, @stdout, @stderr, @worker_thread = Open3.popen3(*serena_command, pgroup: true)
+      send_request(
+        method: "initialize",
+        params: {
+          protocolVersion: "2024-11-05",
+          capabilities: {
+            tools: {}
+          },
+          clientInfo: {
+            name: "Elelem",
+            version: Elelem::VERSION
+          }
+        }
+      )
+      @tools = send_request(method: "tools/list")&.dig("tools") || []
+    end
+
+    def connected?
+      @worker_thread&.alive? && @stdin && !@stdin.closed?
+    end
+
+    def call_tool(name, arguments = {})
+      send_request(
+        method: "tools/call",
+        params: {
+          name: name,
+          arguments: arguments
+        }
+      )
+    end
+
+    private
+
+    attr_reader :stdin, :stdout, :stderr, :worker_thread
+    attr_reader :configuration
+
+    def serena_command
+      [
+        "uvx",
+        "--from",
+        "git+https://github.com/oraios/serena",
+        "serena",
+        "start-mcp-server",
+        "--transport", "stdio",
+        "--context", "ide-assistant",
+        "--project", Dir.pwd,
+      ]
+    end
+
+    def send_request(method:, params: {})
+      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 = JSON.parse(@stdout.gets.strip)
+      configuration.logger.debug(JSON.pretty_generate(response))
+      if response["error"]
+        configuration.logger.error(response["error"])
+        {}
+      else
+        response["result"]
+      end
+    end
+  end
+end
lib/elelem/state.rb
@@ -53,7 +53,7 @@ module Elelem
         if message["thinking"] && !message["thinking"]&.empty?
           unless @progress_shown
             agent.show_progress("Thinking...", "[*]", colour: :yellow)
-            agent.say("\n", newline: false)
+            agent.say("\n\n", newline: false)
             @progress_shown = true
           end
           agent.say(message["thinking"], colour: :gray, newline: false)
@@ -72,13 +72,12 @@ module Elelem
             tool_name = tool_call.dig("function", "name") || "unknown"
             agent.show_progress(tool_name, "[>]", colour: :magenta)
             agent.say("\n\n", newline: false)
-            
+
             result = agent.execute(tool_call)
             agent.conversation.add(role: :tool, content: result)
-            
-            agent.say("\n", newline: false)
-            agent.complete_progress("Tool completed")
+
             agent.say("\n", newline: false)
+            agent.complete_progress("#{tool_name} completed")
           end
         end
 
@@ -103,7 +102,7 @@ module Elelem
           agent.say(message["content"], colour: :default, newline: false)
           self
         else
-          agent.say("\n", newline: true)
+          agent.say("\n\n", newline: false)
           Waiting.new(agent).process(message)
         end
       end
@@ -113,7 +112,7 @@ module Elelem
       agent.logger.debug("Working...")
       agent.show_progress("Processing...", "[.]", colour: :cyan)
       agent.say("\n\n", newline: false)
-      
+
       state = Waiting.new(agent)
       done = false
 
lib/elelem/tools.rb
@@ -19,17 +19,34 @@ module Elelem
         handler: lambda { |args|
           stdout, stderr, _status = Open3.capture3("/bin/sh", "-c", args["command"])
           stdout + stderr
-        }
+        },
       },
     ]
 
     def initialize(configuration, tools = DEFAULT_TOOLS)
       @configuration = configuration
-      @tools = tools
+      client = MCPClient.new(configuration)
+      @tools = tools + client.tools.map do |tool|
+        configuration.logger.debug(tool)
+        {
+          type: "function",
+          function: {
+            name: tool["name"],
+            description: tool["description"],
+            parameters: tool["inputSchema"] || {}
+          },
+          handler: lambda { |args|
+            result = client.call_tool(tool["name"], args)
+            output = result.dig("content", 0, "text") || result.to_s
+            configuration.tui.say(output)
+            return output
+          },
+        }
+      end
     end
 
     def banner
-      @tools.map do |h|
+      tools.map do |h|
         [
           h.dig(:function, :name),
           h.dig(:function, :description)
@@ -41,16 +58,14 @@ module Elelem
       name = tool_call.dig("function", "name")
       args = tool_call.dig("function", "arguments")
 
-      tool = @tools.find do |tool|
-        tool.dig(:function, :name) == name
-      end
-      tool&.fetch(:handler)&.call(args).tap do |result|
+      tool = tools.find { |tool| tool.dig(:function, :name) == name }
+      tool.fetch(:handler).call(args).tap do |result|
         configuration.tui.say(result)
       end
     end
 
     def to_h
-      @tools.map do |tool|
+      tools.map do |tool|
         {
           type: tool[:type],
           function: {
@@ -64,6 +79,6 @@ module Elelem
 
     private
 
-    attr_reader :configuration
+    attr_reader :configuration, :tools
   end
 end
lib/elelem.rb
@@ -4,6 +4,7 @@ require "json"
 require "logger"
 require "net/http"
 require "open3"
+require "securerandom"
 require "thor"
 require "uri"
 
@@ -12,6 +13,7 @@ require_relative "elelem/api"
 require_relative "elelem/application"
 require_relative "elelem/configuration"
 require_relative "elelem/conversation"
+require_relative "elelem/mcp_client"
 require_relative "elelem/state"
 require_relative "elelem/tools"
 require_relative "elelem/tui"
elelem.gemspec
@@ -14,9 +14,7 @@ Gem::Specification.new do |spec|
   spec.license = "MIT"
   spec.required_ruby_version = ">= 3.1.0"
   spec.required_rubygems_version = ">= 3.3.11"
-
   spec.metadata["allowed_push_host"] = "https://rubygems.org"
-
   spec.metadata["homepage_uri"] = spec.homepage
   spec.metadata["source_code_uri"] = "https://gitlab.com/mokhax/elelem"
   spec.metadata["changelog_uri"] = "https://gitlab.com/mokhax/elelem/-/blob/main/CHANGELOG.md"
@@ -37,6 +35,7 @@ Gem::Specification.new do |spec|
   spec.add_dependency "logger"
   spec.add_dependency "net-http"
   spec.add_dependency "open3"
+  spec.add_dependency "securerandom"
   spec.add_dependency "thor"
   spec.add_dependency "uri"
 end
Gemfile.lock
@@ -6,6 +6,7 @@ PATH
       logger
       net-http
       open3
+      securerandom
       thor
       uri
 
@@ -76,6 +77,7 @@ GEM
       parser (>= 3.3.7.2)
       prism (~> 1.4)
     ruby-progressbar (1.13.0)
+    securerandom (0.4.1)
     stringio (3.1.7)
     thor (1.3.2)
     unicode-display_width (3.1.4)