Commit 7d3f642
Changed files (6)
lib
elelem
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