elelem-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a05829dc49559ed0c16cfc5eaf5ac9241fc885c66b5cb6916ade3d6b68047742
4
+ data.tar.gz: 47b5e8f31f8904bd25ee8e4324cd0a76c710a4c967c98f2758f334f4d484508f
5
+ SHA512:
6
+ metadata.gz: ae2d3fc5652d3c659b4848d3cc11ad2eb6810d1618c9bd057536ed99598c8c084a86d9f9c4109ef154530d7c6f752bddc4c0b31da9b16644b88d29d0df722026
7
+ data.tar.gz: b0292705cd0fdc52c9834186fadaed62201c8d61d4ff17c5c311c196bceb203c664b19556edec8c2b27a9ebc190b2665a7b4c8b26d5f6f7ca7cecec5ed425529
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 mo khan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module MCP
5
+ # https://modelcontextprotocol.io/specification/2025-11-25/server/tools.md
6
+ class Client
7
+ CONFIG_PATHS = [
8
+ "~/.agents/mcp.json",
9
+ ".agents/mcp.json"
10
+ ].freeze
11
+
12
+ def self.memoized
13
+ @memoized ||= new.tap { |client| at_exit { client.close } }
14
+ end
15
+
16
+ def initialize(configurations = CONFIG_PATHS)
17
+ @config = load_config(configurations)
18
+ @servers = {}
19
+ end
20
+
21
+ def tools
22
+ @config.fetch("mcpServers", {}).flat_map do |name, _|
23
+ server(name).tools.map do |tool|
24
+ [
25
+ "#{name}_#{tool["name"]}",
26
+ {
27
+ description: tool["description"],
28
+ params: tool.dig("inputSchema", "properties") || {},
29
+ required: tool.dig("inputSchema", "required") || [],
30
+ fn: ->(a) { server(name).call(tool["name"], a) }
31
+ }
32
+ ]
33
+ end
34
+ end.to_h
35
+ end
36
+
37
+ def close
38
+ @servers.each_value(&:close)
39
+ end
40
+
41
+ private
42
+
43
+ def load_config(configurations)
44
+ configurations.each_with_object({}) do |path, merged|
45
+ file = File.expand_path(path)
46
+ next unless File.exist?(file)
47
+
48
+ config = JSON.parse(IO.read(file))
49
+ servers = config.fetch("mcpServers", {})
50
+ merged["mcpServers"] = (merged["mcpServers"] || {}).merge(servers)
51
+ end
52
+ end
53
+
54
+ def server(name)
55
+ @servers[name] ||= build_server(@config.dig("mcpServers", name))
56
+ end
57
+
58
+ def build_server(config)
59
+ if config["type"] == "http"
60
+ HttpServer.new(url: config["url"], headers: config["headers"] || {})
61
+ else
62
+ Server.new(**config.transform_keys(&:to_sym))
63
+ end
64
+ end
65
+ end
66
+
67
+ module ServerInterface
68
+ def tools
69
+ request("tools/list")["tools"]
70
+ end
71
+
72
+ def call(name, args)
73
+ result = request("tools/call", { name: name, arguments: args })
74
+ Elelem.logger.info({ tool: name, args: args, result: result }.to_json)
75
+ content = extract_content(result)
76
+ result["isError"] ? { error: content } : { content: content }
77
+ end
78
+
79
+ def extract_content(result)
80
+ if (structured = result["structuredContent"])
81
+ structured
82
+ else
83
+ result["content"]&.map { |c| c["text"] }&.join("\n")
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def handshake!
90
+ request("initialize", {
91
+ protocolVersion: "2025-06-18",
92
+ capabilities: {},
93
+ clientInfo: { name: "elelem", version: Elelem::MCP::VERSION }
94
+ })
95
+ notify("notifications/initialized")
96
+ end
97
+ end
98
+
99
+ class Server
100
+ include ServerInterface
101
+
102
+ def initialize(command:, args: [], env: {})
103
+ resolved_env = env.transform_values do |v|
104
+ v.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Missing environment variable: #{$1}") }
105
+ end
106
+ @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
107
+ @id = 0
108
+ handshake!
109
+ end
110
+
111
+ def close
112
+ [@stdin, @stdout, @stderr].each do |io|
113
+ io.close
114
+ rescue IOError
115
+ nil
116
+ end
117
+ @wait.kill
118
+ rescue StandardError
119
+ nil
120
+ end
121
+
122
+ private
123
+
124
+ def request(method, params = {})
125
+ send_msg(id: @id += 1, method: method, params: params)
126
+ read_response(@id)
127
+ end
128
+
129
+ def notify(method, params = {})
130
+ send_msg(method: method, params: params)
131
+ end
132
+
133
+ def send_msg(msg)
134
+ @stdin.puts({ jsonrpc: "2.0", **msg }.to_json)
135
+ @stdin.flush
136
+ end
137
+
138
+ def read_response(id)
139
+ loop do
140
+ line = @stdout.gets
141
+ raise "Server closed" unless line
142
+
143
+ msg = JSON.parse(line)
144
+ return msg["result"] if msg["id"] == id
145
+ raise msg["error"]["message"] if msg["error"]
146
+ end
147
+ end
148
+ end
149
+
150
+ class HttpServer
151
+ include ServerInterface
152
+
153
+ def initialize(url:, headers: {}, read_timeout: 3600, open_timeout: 10)
154
+ @uri = URI.parse(url)
155
+ @headers = resolve_headers(headers)
156
+ @read_timeout = read_timeout
157
+ @open_timeout = open_timeout
158
+ @id = 0
159
+ @session_id = nil
160
+ handshake!
161
+ end
162
+
163
+ def close; end
164
+
165
+ private
166
+
167
+ def resolve_headers(headers)
168
+ headers.transform_values do |v|
169
+ v.gsub(/\$\{(\w+)\}/) do
170
+ ENV[$1] || raise("Missing environment variable: #{$1}")
171
+ end
172
+ end
173
+ end
174
+
175
+ def request(method, params = {})
176
+ msg = { jsonrpc: "2.0", id: @id += 1, method: method, params: params }
177
+ response = post(msg)
178
+ raise response["error"]["message"] if response["error"]
179
+
180
+ response["result"]
181
+ end
182
+
183
+ def notify(method, params = {})
184
+ msg = { jsonrpc: "2.0", method: method, params: params }
185
+ post(msg)
186
+ end
187
+
188
+ def post(msg)
189
+ request = Net::HTTP::Post.new(@uri)
190
+ request["content-type"] = "application/json"
191
+ request_headers.each { |k, v| request[k] = v }
192
+ request.body = JSON.generate(msg)
193
+
194
+ http = Net::HTTP.new(@uri.host, @uri.port)
195
+ http.use_ssl = @uri.scheme == "https"
196
+ http.read_timeout = @read_timeout
197
+ http.open_timeout = @open_timeout
198
+
199
+ http.start do |conn|
200
+ conn.request(request) do |response|
201
+ case response
202
+ when Net::HTTPSuccess
203
+ @session_id ||= response["Mcp-Session-Id"]
204
+ return parse_response(response)
205
+ when Net::HTTPUnauthorized
206
+ response.body
207
+ raise "MCP: server requires OAuth (not yet supported)"
208
+ else
209
+ raise "HTTP #{response.code}: #{response.body}"
210
+ end
211
+ end
212
+ end
213
+ end
214
+
215
+ def request_headers
216
+ base = { "Accept" => "application/json, text/event-stream" }
217
+ base["Mcp-Session-Id"] = @session_id if @session_id
218
+ @headers.merge(base)
219
+ end
220
+
221
+ def parse_response(response)
222
+ if response.content_type&.include?("text/event-stream")
223
+ parse_sse(response)
224
+ elsif response.body && !response.body.empty?
225
+ JSON.parse(response.body)
226
+ end
227
+ end
228
+
229
+ def parse_sse(response)
230
+ buffer = String.new
231
+ result = nil
232
+
233
+ response.read_body do |chunk|
234
+ buffer << chunk
235
+
236
+ while (index = buffer.index("\n"))
237
+ line = buffer.slice!(0, index + 1).strip
238
+ next unless line.start_with?("data: ")
239
+
240
+ result = JSON.parse(line.delete_prefix("data: "))
241
+ end
242
+ end
243
+
244
+ result
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module MCP
5
+ class TokenStorage
6
+ STORAGE_DIR = File.expand_path("~/.config/elelem/tokens")
7
+
8
+ def initialize
9
+ FileUtils.mkdir_p(STORAGE_DIR, mode: 0o700)
10
+ end
11
+
12
+ def save(resource_url, access_token:, refresh_token: nil, expires_in: nil)
13
+ data = {
14
+ access_token: access_token,
15
+ refresh_token: refresh_token,
16
+ expires_at: expires_in ? Time.now.to_i + expires_in : nil
17
+ }
18
+ path = token_path(resource_url)
19
+ File.write(path, data.to_json)
20
+ File.chmod(0o600, path)
21
+ end
22
+
23
+ def load(resource_url)
24
+ path = token_path(resource_url)
25
+ return nil unless File.exist?(path)
26
+
27
+ JSON.parse(File.read(path), symbolize_names: true)
28
+ rescue JSON::ParserError
29
+ nil
30
+ end
31
+
32
+ def save_client(resource_url, client_data)
33
+ path = client_path(resource_url)
34
+ File.write(path, client_data.to_json)
35
+ File.chmod(0o600, path)
36
+ end
37
+
38
+ def load_client(resource_url)
39
+ path = client_path(resource_url)
40
+ return nil unless File.exist?(path)
41
+
42
+ JSON.parse(File.read(path), symbolize_names: true)
43
+ rescue JSON::ParserError
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ def token_path(resource_url)
50
+ hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
51
+ File.join(STORAGE_DIR, "#{hash}.json")
52
+ end
53
+
54
+ def client_path(resource_url)
55
+ hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
56
+ File.join(STORAGE_DIR, "#{hash}_client.json")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module MCP
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/elelem/mcp.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "elelem"
5
+ require "fileutils"
6
+ require "json"
7
+ require "net/http"
8
+ require "open3"
9
+ require "uri"
10
+
11
+ require_relative "mcp/version"
12
+ require_relative "mcp/token_storage"
13
+ require_relative "mcp/client"
14
+
15
+ Elelem::Plugins.register(:mcp) do |agent|
16
+ mcp = Elelem::MCP::Client.memoized
17
+
18
+ Thread.new do
19
+ mcp.tools.each do |name, tool|
20
+ next if agent.toolbox.tools.key?(name)
21
+
22
+ agent.toolbox.add(
23
+ name,
24
+ description: tool[:description],
25
+ params: tool[:params],
26
+ required: tool[:required],
27
+ &tool[:fn]
28
+ )
29
+ end
30
+ rescue => e
31
+ warn "MCP failed: #{e.message}"
32
+ end
33
+ end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: elelem-mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - mo khan
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: digest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: elelem
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: fileutils
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.8'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.8'
54
+ - !ruby/object:Gem::Dependency
55
+ name: json
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.21'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.21'
68
+ - !ruby/object:Gem::Dependency
69
+ name: net-http
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.9'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.9'
82
+ - !ruby/object:Gem::Dependency
83
+ name: open3
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.2'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.2'
96
+ - !ruby/object:Gem::Dependency
97
+ name: uri
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.1'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.1'
110
+ description: Adds MCP server tool discovery (stdio and HTTP transports) as elelem
111
+ tools.
112
+ email:
113
+ - mo@mokhan.ca
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - LICENSE.txt
119
+ - Rakefile
120
+ - lib/elelem/mcp.rb
121
+ - lib/elelem/mcp/client.rb
122
+ - lib/elelem/mcp/token_storage.rb
123
+ - lib/elelem/mcp/version.rb
124
+ homepage: https://src.mokhan.ca/elelem/mcp
125
+ licenses:
126
+ - MIT
127
+ metadata:
128
+ allowed_push_host: https://rubygems.org
129
+ homepage_uri: https://src.mokhan.ca/elelem/mcp
130
+ source_code_uri: https://src.mokhan.ca/elelem/mcp
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: 4.0.0
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 4.0.20
146
+ specification_version: 4
147
+ summary: MCP (Model Context Protocol) client support for elelem.
148
+ test_files: []