Commit 7d3f642

mo khan <mo@mokhan.ca>
2026-09-05 20:58:09
refactor: split features into separate gems
1 parent 3b43165
lib/elelem/mcp/client.rb
@@ -0,0 +1,249 @@
+# frozen_string_literal: true
+
+require "json"
+require "net/http"
+require "open3"
+require "uri"
+
+module Elelem
+  module MCP
+    # https://modelcontextprotocol.io/specification/2025-11-25/server/tools.md
+    class Client
+      CONFIG_PATHS = [
+        "~/.agents/mcp.json",
+        ".agents/mcp.json"
+      ].freeze
+
+      def initialize(configurations = CONFIG_PATHS)
+        @config = load_config(configurations)
+        @servers = {}
+      end
+
+      def tools
+        @config.fetch("mcpServers", {}).flat_map do |name, _|
+          server(name).tools.map do |tool|
+            [
+              "#{name}_#{tool["name"]}",
+              {
+                description: tool["description"],
+                params: tool.dig("inputSchema", "properties") || {},
+                required: tool.dig("inputSchema", "required") || [],
+                fn: ->(a) { server(name).call(tool["name"], a) }
+              }
+            ]
+          end
+        end.to_h
+      end
+
+      def close
+        @servers.each_value(&:close)
+      end
+
+      private
+
+      def load_config(configurations)
+        configurations.each_with_object({}) do |path, merged|
+          file = File.expand_path(path)
+          next unless File.exist?(file)
+
+          config = JSON.parse(IO.read(file))
+          servers = config.fetch("mcpServers", {})
+          merged["mcpServers"] = (merged["mcpServers"] || {}).merge(servers)
+        end
+      end
+
+      def server(name)
+        @servers[name] ||= build_server(@config.dig("mcpServers", name))
+      end
+
+      def build_server(config)
+        if config["type"] == "http"
+          HttpServer.new(url: config["url"], headers: config["headers"] || {})
+        else
+          Server.new(**config.transform_keys(&:to_sym))
+        end
+      end
+    end
+
+    module ServerInterface
+      def tools
+        request("tools/list")["tools"]
+      end
+
+      def call(name, args)
+        result = request("tools/call", { name: name, arguments: args })
+        Elelem.logger.info({ tool: name, args: args, result: result }.to_json)
+        content = extract_content(result)
+        result["isError"] ? { error: content } : { content: content }
+      end
+
+      def extract_content(result)
+        if (structured = result["structuredContent"])
+          structured
+        else
+          result["content"]&.map { |c| c["text"] }&.join("\n")
+        end
+      end
+
+      private
+
+      def handshake!
+        request("initialize", {
+          protocolVersion: "2025-06-18",
+          capabilities: {},
+          clientInfo: { name: "elelem", version: Elelem::VERSION }
+        })
+        notify("notifications/initialized")
+      end
+    end
+
+    class Server
+      include ServerInterface
+
+      def initialize(command:, args: [], env: {})
+        resolved_env = env.transform_values do |v|
+          v.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Missing environment variable: #{$1}") }
+        end
+        @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
+        @id = 0
+        handshake!
+      end
+
+      def close
+        [@stdin, @stdout, @stderr].each do |io|
+          io.close
+        rescue IOError
+          nil
+        end
+        @wait.kill
+      rescue StandardError
+        nil
+      end
+
+      private
+
+      def request(method, params = {})
+        send_msg(id: @id += 1, method: method, params: params)
+        read_response(@id)
+      end
+
+      def notify(method, params = {})
+        send_msg(method: method, params: params)
+      end
+
+      def send_msg(msg)
+        @stdin.puts({ jsonrpc: "2.0", **msg }.to_json)
+        @stdin.flush
+      end
+
+      def read_response(id)
+        loop do
+          line = @stdout.gets
+          raise "Server closed" unless line
+
+          msg = JSON.parse(line)
+          return msg["result"] if msg["id"] == id
+          raise msg["error"]["message"] if msg["error"]
+        end
+      end
+    end
+
+    class HttpServer
+      include ServerInterface
+
+      def initialize(url:, headers: {}, read_timeout: 3600, open_timeout: 10)
+        @uri = URI.parse(url)
+        @headers = resolve_headers(headers)
+        @read_timeout = read_timeout
+        @open_timeout = open_timeout
+        @id = 0
+        @session_id = nil
+        handshake!
+      end
+
+      def close; end
+
+      private
+
+      def resolve_headers(headers)
+        headers.transform_values do |v|
+          v.gsub(/\$\{(\w+)\}/) do
+            ENV[$1] || raise("Missing environment variable: #{$1}")
+          end
+        end
+      end
+
+      def request(method, params = {})
+        msg = { jsonrpc: "2.0", id: @id += 1, method: method, params: params }
+        response = post(msg)
+        raise response["error"]["message"] if response["error"]
+
+        response["result"]
+      end
+
+      def notify(method, params = {})
+        msg = { jsonrpc: "2.0", method: method, params: params }
+        post(msg)
+      end
+
+      def post(msg)
+        request = Net::HTTP::Post.new(@uri)
+        request["content-type"] = "application/json"
+        request_headers.each { |k, v| request[k] = v }
+        request.body = JSON.generate(msg)
+
+        http = Net::HTTP.new(@uri.host, @uri.port)
+        http.use_ssl = @uri.scheme == "https"
+        http.read_timeout = @read_timeout
+        http.open_timeout = @open_timeout
+
+        http.start do |conn|
+          conn.request(request) do |response|
+            case response
+            when Net::HTTPSuccess
+              @session_id ||= response["Mcp-Session-Id"]
+              return parse_response(response)
+            when Net::HTTPUnauthorized
+              response.body
+              raise "MCP: server requires OAuth (not yet supported)"
+            else
+              raise "HTTP #{response.code}: #{response.body}"
+            end
+          end
+        end
+      end
+
+      def request_headers
+        base = { "Accept" => "application/json, text/event-stream" }
+        base["Mcp-Session-Id"] = @session_id if @session_id
+        @headers.merge(base)
+      end
+
+      def parse_response(response)
+        if response.content_type&.include?("text/event-stream")
+          parse_sse(response)
+        elsif response.body && !response.body.empty?
+          JSON.parse(response.body)
+        end
+      end
+
+      def parse_sse(response)
+        buffer = String.new
+        result = nil
+
+        response.read_body do |chunk|
+          buffer << chunk
+
+          while (index = buffer.index("\n"))
+            line = buffer.slice!(0, index + 1).strip
+            next unless line.start_with?("data: ")
+
+            result = JSON.parse(line.delete_prefix("data: "))
+          end
+        end
+
+        result
+      end
+    end
+  end
+end
lib/elelem/mcp/plugin.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require_relative "../mcp"
+
+Elelem::Plugins.register(:mcp) do |agent|
+  mcp = Elelem::MCP::Client.new
+  at_exit { mcp.close }
+
+  Thread.new do
+    mcp.tools.each do |name, tool|
+      agent.toolbox.add(
+        name,
+        description: tool[:description],
+        params: tool[:params],
+        required: tool[:required],
+        &tool[:fn]
+      )
+    end
+  rescue => e
+    warn "MCP failed: #{e.message}"
+  end
+end
lib/elelem/mcp/token_storage.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "digest"
+require "fileutils"
+require "json"
+
+module Elelem
+  module MCP
+    class TokenStorage
+      STORAGE_DIR = File.expand_path("~/.config/elelem/tokens")
+
+      def initialize
+        FileUtils.mkdir_p(STORAGE_DIR, mode: 0o700)
+      end
+
+      def save(resource_url, access_token:, refresh_token: nil, expires_in: nil)
+        data = {
+          access_token: access_token,
+          refresh_token: refresh_token,
+          expires_at: expires_in ? Time.now.to_i + expires_in : nil
+        }
+        path = token_path(resource_url)
+        File.write(path, data.to_json)
+        File.chmod(0o600, path)
+      end
+
+      def load(resource_url)
+        path = token_path(resource_url)
+        return nil unless File.exist?(path)
+
+        JSON.parse(File.read(path), symbolize_names: true)
+      rescue JSON::ParserError
+        nil
+      end
+
+      def save_client(resource_url, client_data)
+        path = client_path(resource_url)
+        File.write(path, client_data.to_json)
+        File.chmod(0o600, path)
+      end
+
+      def load_client(resource_url)
+        path = client_path(resource_url)
+        return nil unless File.exist?(path)
+
+        JSON.parse(File.read(path), symbolize_names: true)
+      rescue JSON::ParserError
+        nil
+      end
+
+      private
+
+      def token_path(resource_url)
+        hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
+        File.join(STORAGE_DIR, "#{hash}.json")
+      end
+
+      def client_path(resource_url)
+        hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
+        File.join(STORAGE_DIR, "#{hash}_client.json")
+      end
+    end
+  end
+end
lib/elelem/mcp/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Elelem
+  module MCP
+    VERSION = "0.1.0"
+  end
+end
lib/elelem/mcp.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require_relative "mcp/version"
+require_relative "mcp/token_storage"
+require_relative "mcp/client"
elelem-mcp.gemspec
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require_relative "lib/elelem/mcp/version"
+
+Gem::Specification.new do |spec|
+  spec.name = "elelem-mcp"
+  spec.version = Elelem::MCP::VERSION
+  spec.authors = ["mo khan"]
+  spec.email = ["mo@mokhan.ca"]
+
+  spec.summary = "MCP (Model Context Protocol) client support for elelem."
+  spec.description = "Adds MCP server tool discovery (stdio and HTTP transports) as elelem tools."
+  spec.homepage = "https://src.mokhan.ca/xlgmokha/elelem"
+  spec.license = "MIT"
+  spec.required_ruby_version = ">= 4.0.0"
+  spec.required_rubygems_version = ">= 4.0.0"
+  spec.metadata["allowed_push_host"] = "https://rubygems.org"
+  spec.metadata["homepage_uri"] = spec.homepage
+  spec.metadata["source_code_uri"] = "https://git.mokhan.ca/xlgmokha/elelem.git"
+
+  spec.files = [
+    "LICENSE.txt",
+    "lib/elelem/mcp.rb",
+    "lib/elelem/mcp/version.rb",
+    "lib/elelem/mcp/client.rb",
+    "lib/elelem/mcp/token_storage.rb",
+    "lib/elelem/mcp/plugin.rb",
+  ]
+  spec.require_paths = ["lib"]
+
+  spec.add_dependency "elelem", "~> 0.10"
+  spec.add_dependency "json", "~> 2.0"
+end