Commit 3b43165

mo khan <mo@mokhan.ca>
2026-09-05 05:12:01
chore: remove MCP feature and the ollama provider
gguf is now the only provider, so drop lib/elelem/mcp{.rb,/}, the mcp and ollama plugins, net/ollama.rb, and the now-unused net-hippie dependency and Net.http client. Default provider switches from ollama to gguf in Elelem.start/serve/ask and the CLI. Verified: elelem loads cleanly, Providers.names is ["gguf"], and a live Elelem.ask (no explicit provider) round-trips through the gguf model. Claude-Session: https://claude.ai/code/session_01FpbgyAMtPEkDbo2kx78qR6
1 parent a4b2c58
Changed files (3)
lib/elelem/mcp/oauth.rb
@@ -1,217 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class MCP
-    class OAuth
-      CALLBACK_PORT = 18273
-      REDIRECT_URI = "http://127.0.0.1:#{CALLBACK_PORT}/callback"
-
-      def initialize(resource_url, http: Elelem::Net.http)
-        @resource_url = resource_url
-        @http = http
-        @storage = TokenStorage.new
-      end
-
-      def token
-        stored = @storage.load(@resource_url)
-        return stored[:access_token] if stored && !expired?(stored)
-        return refresh(stored[:refresh_token]) if stored&.dig(:refresh_token)
-
-        authorize
-      end
-
-      private
-
-      def expired?(stored)
-        return false unless stored[:expires_at]
-
-        Time.now.to_i >= stored[:expires_at] - 60
-      end
-
-      def authorize
-        metadata = discover_auth_server
-        client = load_or_register_client(metadata)
-        verifier, challenge = generate_pkce
-        state = SecureRandom.hex(16)
-
-        auth_url = build_auth_url(metadata, client, challenge, state)
-        open_browser(auth_url)
-
-        code = wait_for_callback(state)
-        tokens = exchange_code(metadata, client, code, verifier)
-
-        @storage.save(
-          @resource_url,
-          access_token: tokens["access_token"],
-          refresh_token: tokens["refresh_token"],
-          expires_in: tokens["expires_in"]
-        )
-
-        tokens["access_token"]
-      end
-
-      def refresh(refresh_token)
-        metadata = discover_auth_server
-        client = load_or_register_client(metadata)
-        uri = URI.parse(metadata["token_endpoint"])
-
-        body = {
-          grant_type: "refresh_token",
-          refresh_token: refresh_token,
-          client_id: client[:client_id]
-        }
-
-        response = post_form(uri, body)
-        tokens = JSON.parse(response.body)
-
-        @storage.save(
-          @resource_url,
-          access_token: tokens["access_token"],
-          refresh_token: tokens["refresh_token"] || refresh_token,
-          expires_in: tokens["expires_in"]
-        )
-
-        tokens["access_token"]
-      rescue StandardError => e
-        warn "Token refresh failed: #{e.message}"
-        authorize
-      end
-
-      def discover_auth_server
-        resource_uri = URI.parse(@resource_url)
-        metadata_url = "#{resource_uri.scheme}://#{resource_uri.host}/.well-known/oauth-protected-resource"
-
-        resource_metadata = fetch_json(metadata_url)
-        auth_server_url = resource_metadata["authorization_servers"]&.first
-        raise "No authorization server found" unless auth_server_url
-
-        auth_metadata_url = "#{auth_server_url}/.well-known/oauth-authorization-server"
-        fetch_json(auth_metadata_url)
-      end
-
-      def load_or_register_client(metadata)
-        stored = @storage.load_client(@resource_url)
-        return stored if stored
-
-        client = register_client(metadata)
-        @storage.save_client(@resource_url, client)
-        @storage.load_client(@resource_url)
-      end
-
-      def register_client(metadata)
-        endpoint = metadata["registration_endpoint"]
-        raise "Dynamic registration not supported" unless endpoint
-
-        body = {
-          client_name: "elelem",
-          redirect_uris: [REDIRECT_URI],
-          grant_types: %w[authorization_code refresh_token],
-          response_types: ["code"],
-          token_endpoint_auth_method: "none"
-        }
-
-        response = post_json(endpoint, body)
-        JSON.parse(response.body)
-      end
-
-      def generate_pkce
-        verifier = SecureRandom.urlsafe_base64(32)
-        challenge = Base64.urlsafe_encode64(
-          Digest::SHA256.digest(verifier),
-          padding: false
-        )
-        [verifier, challenge]
-      end
-
-      def build_auth_url(metadata, client, challenge, state)
-        params = {
-          response_type: "code",
-          client_id: client[:client_id],
-          redirect_uri: REDIRECT_URI,
-          scope: metadata["scopes_supported"]&.join(" ") || "openid",
-          state: state,
-          code_challenge: challenge,
-          code_challenge_method: "S256"
-        }
-
-        "#{metadata["authorization_endpoint"]}?#{URI.encode_www_form(params)}"
-      end
-
-      def open_browser(url)
-        commands = ["xdg-open", "open", "start"]
-        commands.each do |cmd|
-          return if system(cmd, url, out: File::NULL, err: File::NULL)
-        end
-        warn "Open this URL in your browser: #{url}"
-      end
-
-      def wait_for_callback(expected_state)
-        code = nil
-        @server = WEBrick::HTTPServer.new(
-          Port: CALLBACK_PORT,
-          Logger: WEBrick::Log.new(File::NULL),
-          AccessLog: []
-        )
-
-        at_exit { @server&.shutdown }
-
-        @server.mount_proc("/callback") do |req, res|
-          state = req.query["state"]
-          raise "State mismatch" unless state == expected_state
-
-          code = req.query["code"]
-          res.content_type = "text/html"
-          res.body = "<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
-          @server.shutdown
-        end
-
-        Timeout.timeout(120) { @server.start }
-        code
-      rescue Timeout::Error
-        @server.shutdown
-        raise "OAuth callback timed out"
-      end
-
-      def exchange_code(metadata, client, code, verifier)
-        uri = URI.parse(metadata["token_endpoint"])
-
-        body = {
-          grant_type: "authorization_code",
-          code: code,
-          redirect_uri: REDIRECT_URI,
-          client_id: client[:client_id],
-          code_verifier: verifier
-        }
-
-        response = post_form(uri, body)
-        JSON.parse(response.body)
-      end
-
-      def fetch_json(url)
-        response = nil
-        @http.get(url) { |r| response = r }
-        JSON.parse(response.body)
-      end
-
-      def post_json(url, body)
-        response = nil
-        @http.post(
-          url,
-          headers: { "Content-Type" => "application/json" },
-          body: body.to_json
-        ) { |r| response = r }
-        response
-      end
-
-      def post_form(uri, body)
-        response = nil
-        @http.post(
-          uri.to_s,
-          headers: { "Content-Type" => "application/x-www-form-urlencoded" },
-          body: URI.encode_www_form(body)
-        ) { |r| response = r }
-        response
-      end
-    end
-  end
-end
lib/elelem/mcp/token_storage.rb
@@ -1,60 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class 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.rb
@@ -1,239 +0,0 @@
-# frozen_string_literal: true
-
-require_relative "mcp/token_storage"
-require_relative "mcp/oauth"
-
-module Elelem
-  # https://modelcontextprotocol.io/specification/2025-11-25/server/tools.md
-  class MCP
-    CONFIG_PATHS = [
-      "~/.elelem/mcp.json",
-      ".elelem/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
-
-    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: 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 { |io| io.close rescue nil }
-        @wait.kill rescue 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: {}, http: Elelem::Net.http)
-        @url = url
-        @headers = resolve_headers(headers)
-        @http = http
-        @id = 0
-        @session_id = nil
-        @access_token = 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, retry_auth: true)
-        result = nil
-        needs_auth = false
-        error = nil
-
-        @http.post(@url, headers: request_headers, body: msg) do |response|
-          case response
-          when ::Net::HTTPSuccess
-            @session_id ||= response["Mcp-Session-Id"]
-            result = parse_response(response)
-          when ::Net::HTTPUnauthorized
-            needs_auth = true
-          else
-            error = "HTTP #{response.code}: #{response.body}"
-          end
-        end
-
-        raise error if error
-        if needs_auth
-          raise "Authorization failed" unless retry_auth
-
-          @access_token = OAuth.new(@url, http: @http).token
-          return post(msg, retry_auth: false)
-        end
-        result
-      end
-
-      def request_headers
-        base = { "Accept" => "application/json, text/event-stream" }
-        base["Mcp-Session-Id"] = @session_id if @session_id
-        base["Authorization"] = "Bearer #{@access_token}" if @access_token
-        @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