zeromcp 0.1.1 → 0.2.2
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 +4 -4
- data/lib/zeromcp/config.rb +46 -2
- data/lib/zeromcp/scanner.rb +239 -0
- data/lib/zeromcp/server.rb +318 -42
- data/zeromcp.gemspec +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2e5b203cdc7f03afff0cf6ce507e9480455b3f16e469222d5934af9351ffa9a
|
|
4
|
+
data.tar.gz: 8e968d22d810a2f25962eb5de0f242438f3ea4359c40cee4616398280695c6f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5af7807f278f6430cd65fad75104d90536fbe5c840a2dfce3b7c125415940d65f4166e420f06b085c1ff6fe60c90a525773956e4e341c3b20098258227296a5
|
|
7
|
+
data.tar.gz: 3c0c7c8350efedaf3bf7ec4342e85f817b6da45e6668d6aace1553ef98edc478143db42acc4652b119a179c7b4580ddef29c54226457d03b3ed88d4582e6bd09
|
data/lib/zeromcp/config.rb
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'base64'
|
|
4
5
|
|
|
5
6
|
module ZeroMcp
|
|
6
7
|
class Config
|
|
7
|
-
attr_reader :tools_dir, :
|
|
8
|
+
attr_reader :tools_dir, :resources_dir, :prompts_dir,
|
|
9
|
+
:separator, :logging, :bypass_permissions, :execute_timeout,
|
|
10
|
+
:page_size, :icon
|
|
8
11
|
|
|
9
12
|
def initialize(opts = {})
|
|
10
13
|
tools = opts[:tools_dir] || opts['tools'] || './tools'
|
|
11
14
|
@tools_dir = tools.is_a?(Array) ? tools : [tools]
|
|
15
|
+
|
|
16
|
+
resources = opts[:resources_dir] || opts['resources']
|
|
17
|
+
@resources_dir = resources ? (resources.is_a?(Array) ? resources : [resources]) : []
|
|
18
|
+
|
|
19
|
+
prompts = opts[:prompts_dir] || opts['prompts']
|
|
20
|
+
@prompts_dir = prompts ? (prompts.is_a?(Array) ? prompts : [prompts]) : []
|
|
21
|
+
|
|
12
22
|
@separator = opts[:separator] || opts['separator'] || '_'
|
|
13
23
|
@logging = opts[:logging] || opts['logging'] || false
|
|
14
24
|
@bypass_permissions = opts[:bypass_permissions] || opts['bypass_permissions'] || false
|
|
15
25
|
@execute_timeout = opts[:execute_timeout] || opts['execute_timeout'] || 30 # seconds
|
|
16
26
|
@credentials = opts[:credentials] || opts['credentials'] || {}
|
|
27
|
+
@cache_credentials = opts.key?(:cache_credentials) ? opts[:cache_credentials] : (opts.key?('cache_credentials') ? opts['cache_credentials'] : true)
|
|
17
28
|
@namespacing = opts[:namespacing] || opts['namespacing'] || {}
|
|
29
|
+
@page_size = opts[:page_size] || opts['page_size'] || 0
|
|
30
|
+
@icon = opts[:icon] || opts['icon']
|
|
18
31
|
end
|
|
19
32
|
|
|
20
|
-
attr_reader :credentials, :namespacing
|
|
33
|
+
attr_reader :credentials, :cache_credentials, :namespacing
|
|
21
34
|
|
|
22
35
|
def self.load(path = nil)
|
|
23
36
|
path ||= File.join(Dir.pwd, 'zeromcp.config.json')
|
|
@@ -29,5 +42,36 @@ module ZeroMcp
|
|
|
29
42
|
rescue JSON::ParserError
|
|
30
43
|
new
|
|
31
44
|
end
|
|
45
|
+
|
|
46
|
+
# Resolve an icon config value to a data URI. Supports:
|
|
47
|
+
# - data: URIs (returned as-is)
|
|
48
|
+
# - Local file paths (read and base64 encode)
|
|
49
|
+
# Returns nil on failure or if icon is not set.
|
|
50
|
+
def self.resolve_icon(icon)
|
|
51
|
+
return nil if icon.nil? || icon.empty?
|
|
52
|
+
return icon if icon.start_with?('data:')
|
|
53
|
+
|
|
54
|
+
# File path
|
|
55
|
+
path = File.expand_path(icon)
|
|
56
|
+
return nil unless File.exist?(path)
|
|
57
|
+
|
|
58
|
+
ext = File.extname(path).downcase
|
|
59
|
+
mime = ICON_MIME[ext] || 'image/png'
|
|
60
|
+
data = File.binread(path)
|
|
61
|
+
"data:#{mime};base64,#{Base64.strict_encode64(data)}"
|
|
62
|
+
rescue => e
|
|
63
|
+
$stderr.puts "[zeromcp] Warning: failed to read icon file #{icon}: #{e.message}"
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ICON_MIME = {
|
|
68
|
+
'.png' => 'image/png',
|
|
69
|
+
'.jpg' => 'image/jpeg',
|
|
70
|
+
'.jpeg' => 'image/jpeg',
|
|
71
|
+
'.gif' => 'image/gif',
|
|
72
|
+
'.svg' => 'image/svg+xml',
|
|
73
|
+
'.ico' => 'image/x-icon',
|
|
74
|
+
'.webp' => 'image/webp'
|
|
75
|
+
}.freeze
|
|
32
76
|
end
|
|
33
77
|
end
|
data/lib/zeromcp/scanner.rb
CHANGED
|
@@ -118,4 +118,243 @@ module ZeroMcp
|
|
|
118
118
|
@definition
|
|
119
119
|
end
|
|
120
120
|
end
|
|
121
|
+
|
|
122
|
+
# --- Resource scanning ---
|
|
123
|
+
|
|
124
|
+
MIME_MAP = {
|
|
125
|
+
'.json' => 'application/json',
|
|
126
|
+
'.txt' => 'text/plain',
|
|
127
|
+
'.md' => 'text/markdown',
|
|
128
|
+
'.html' => 'text/html',
|
|
129
|
+
'.xml' => 'application/xml',
|
|
130
|
+
'.yaml' => 'text/yaml',
|
|
131
|
+
'.yml' => 'text/yaml',
|
|
132
|
+
'.csv' => 'text/csv',
|
|
133
|
+
'.css' => 'text/css',
|
|
134
|
+
'.js' => 'application/javascript',
|
|
135
|
+
'.ts' => 'text/typescript',
|
|
136
|
+
'.sql' => 'text/plain',
|
|
137
|
+
'.sh' => 'text/plain',
|
|
138
|
+
'.py' => 'text/plain',
|
|
139
|
+
'.go' => 'text/plain',
|
|
140
|
+
'.rs' => 'text/plain',
|
|
141
|
+
'.toml' => 'text/plain',
|
|
142
|
+
'.ini' => 'text/plain',
|
|
143
|
+
'.env' => 'text/plain'
|
|
144
|
+
}.freeze
|
|
145
|
+
|
|
146
|
+
class ResourceScanner
|
|
147
|
+
attr_reader :resources, :templates
|
|
148
|
+
|
|
149
|
+
def initialize(config)
|
|
150
|
+
@config = config
|
|
151
|
+
@resources = {}
|
|
152
|
+
@templates = {}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def scan
|
|
156
|
+
@resources.clear
|
|
157
|
+
@templates.clear
|
|
158
|
+
|
|
159
|
+
@config.resources_dir.each do |d|
|
|
160
|
+
dir = File.expand_path(d)
|
|
161
|
+
unless Dir.exist?(dir)
|
|
162
|
+
$stderr.puts "[zeromcp] Cannot read resources directory: #{dir}"
|
|
163
|
+
next
|
|
164
|
+
end
|
|
165
|
+
scan_dir(dir, dir)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def scan_dir(dir, root_dir)
|
|
172
|
+
Dir.entries(dir).sort.each do |entry|
|
|
173
|
+
next if entry.start_with?('.')
|
|
174
|
+
|
|
175
|
+
full_path = File.join(dir, entry)
|
|
176
|
+
|
|
177
|
+
if File.directory?(full_path)
|
|
178
|
+
scan_dir(full_path, root_dir)
|
|
179
|
+
elsif File.file?(full_path)
|
|
180
|
+
rel = Pathname.new(full_path).relative_path_from(Pathname.new(root_dir)).to_s
|
|
181
|
+
ext = File.extname(entry)
|
|
182
|
+
name = rel.sub(/\.[^.]+$/, '').gsub(%r{[\\/]}, @config.separator)
|
|
183
|
+
|
|
184
|
+
if ext == '.rb'
|
|
185
|
+
load_dynamic(full_path, name)
|
|
186
|
+
else
|
|
187
|
+
load_static(full_path, rel, name, ext)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def load_dynamic(file_path, name)
|
|
194
|
+
loader = ResourceLoader.new
|
|
195
|
+
loader.instance_eval(File.read(file_path), file_path)
|
|
196
|
+
defn = loader._definition
|
|
197
|
+
return unless defn && defn[:read]
|
|
198
|
+
|
|
199
|
+
if defn[:uri_template]
|
|
200
|
+
@templates[name] = {
|
|
201
|
+
uri_template: defn[:uri_template],
|
|
202
|
+
name: name,
|
|
203
|
+
description: defn[:description],
|
|
204
|
+
mime_type: defn[:mime_type] || 'application/json',
|
|
205
|
+
read: defn[:read]
|
|
206
|
+
}
|
|
207
|
+
else
|
|
208
|
+
uri = defn[:uri] || "resource:///#{name}"
|
|
209
|
+
@resources[name] = {
|
|
210
|
+
uri: uri,
|
|
211
|
+
name: name,
|
|
212
|
+
description: defn[:description],
|
|
213
|
+
mime_type: defn[:mime_type] || 'application/json',
|
|
214
|
+
read: defn[:read]
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
$stderr.puts "[zeromcp] Resource loaded: #{name}"
|
|
219
|
+
rescue => e
|
|
220
|
+
$stderr.puts "[zeromcp] Error loading resource #{file_path}: #{e.message}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def load_static(file_path, rel_path, name, ext)
|
|
224
|
+
uri = "resource:///#{rel_path.gsub('\\', '/')}"
|
|
225
|
+
mime_type = MIME_MAP[ext] || 'application/octet-stream'
|
|
226
|
+
|
|
227
|
+
@resources[name] = {
|
|
228
|
+
uri: uri,
|
|
229
|
+
name: name,
|
|
230
|
+
description: "Static resource: #{rel_path}",
|
|
231
|
+
mime_type: mime_type,
|
|
232
|
+
read: -> { File.read(file_path, encoding: 'UTF-8') }
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# DSL for dynamic resource .rb files
|
|
238
|
+
class ResourceLoader
|
|
239
|
+
def initialize
|
|
240
|
+
@definition = {}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def resource(description: nil, mime_type: nil, uri: nil, uri_template: nil)
|
|
244
|
+
@definition[:description] = description
|
|
245
|
+
@definition[:mime_type] = mime_type
|
|
246
|
+
@definition[:uri] = uri
|
|
247
|
+
@definition[:uri_template] = uri_template
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def read(&block)
|
|
251
|
+
@definition[:read] = block
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def _definition
|
|
255
|
+
return nil unless @definition[:read]
|
|
256
|
+
@definition
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# --- Prompt scanning ---
|
|
261
|
+
|
|
262
|
+
class PromptScanner
|
|
263
|
+
attr_reader :prompts
|
|
264
|
+
|
|
265
|
+
def initialize(config)
|
|
266
|
+
@config = config
|
|
267
|
+
@prompts = {}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def scan
|
|
271
|
+
@prompts.clear
|
|
272
|
+
|
|
273
|
+
@config.prompts_dir.each do |d|
|
|
274
|
+
dir = File.expand_path(d)
|
|
275
|
+
unless Dir.exist?(dir)
|
|
276
|
+
$stderr.puts "[zeromcp] Cannot read prompts directory: #{dir}"
|
|
277
|
+
next
|
|
278
|
+
end
|
|
279
|
+
scan_dir(dir, dir)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
def scan_dir(dir, root_dir)
|
|
286
|
+
Dir.entries(dir).sort.each do |entry|
|
|
287
|
+
next if entry.start_with?('.')
|
|
288
|
+
|
|
289
|
+
full_path = File.join(dir, entry)
|
|
290
|
+
|
|
291
|
+
if File.directory?(full_path)
|
|
292
|
+
scan_dir(full_path, root_dir)
|
|
293
|
+
elsif entry.end_with?('.rb')
|
|
294
|
+
rel = Pathname.new(full_path).relative_path_from(Pathname.new(root_dir)).to_s
|
|
295
|
+
name = rel.sub(/\.rb$/, '').gsub(%r{[\\/]}, @config.separator)
|
|
296
|
+
load_prompt(full_path, name)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def load_prompt(file_path, name)
|
|
302
|
+
loader = PromptLoader.new
|
|
303
|
+
loader.instance_eval(File.read(file_path), file_path)
|
|
304
|
+
defn = loader._definition
|
|
305
|
+
return unless defn && defn[:render]
|
|
306
|
+
|
|
307
|
+
# Convert arguments hash to MCP prompt arguments array
|
|
308
|
+
prompt_args = nil
|
|
309
|
+
if defn[:arguments] && !defn[:arguments].empty?
|
|
310
|
+
prompt_args = defn[:arguments].map do |key, val|
|
|
311
|
+
key = key.to_s
|
|
312
|
+
if val.is_a?(String)
|
|
313
|
+
{ 'name' => key, 'required' => true }
|
|
314
|
+
elsif val.is_a?(Hash)
|
|
315
|
+
entry = { 'name' => key }
|
|
316
|
+
desc = val[:description] || val['description']
|
|
317
|
+
entry['description'] = desc if desc
|
|
318
|
+
optional = val[:optional] || val['optional']
|
|
319
|
+
entry['required'] = !optional
|
|
320
|
+
entry
|
|
321
|
+
else
|
|
322
|
+
{ 'name' => key, 'required' => true }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
@prompts[name] = {
|
|
328
|
+
name: name,
|
|
329
|
+
description: defn[:description],
|
|
330
|
+
arguments: prompt_args,
|
|
331
|
+
render: defn[:render]
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
$stderr.puts "[zeromcp] Prompt loaded: #{name}"
|
|
335
|
+
rescue => e
|
|
336
|
+
$stderr.puts "[zeromcp] Error loading prompt #{file_path}: #{e.message}"
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# DSL for prompt .rb files
|
|
341
|
+
class PromptLoader
|
|
342
|
+
def initialize
|
|
343
|
+
@definition = {}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def prompt(description: nil, arguments: {})
|
|
347
|
+
@definition[:description] = description
|
|
348
|
+
@definition[:arguments] = arguments
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def render(&block)
|
|
352
|
+
@definition[:render] = block
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def _definition
|
|
356
|
+
return nil unless @definition[:render]
|
|
357
|
+
@definition
|
|
358
|
+
end
|
|
359
|
+
end
|
|
121
360
|
end
|
data/lib/zeromcp/server.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'base64'
|
|
4
5
|
require 'timeout'
|
|
5
6
|
require_relative 'schema'
|
|
6
7
|
require_relative 'config'
|
|
@@ -13,14 +14,32 @@ module ZeroMcp
|
|
|
13
14
|
def initialize(config = nil)
|
|
14
15
|
@config = config || Config.load
|
|
15
16
|
@scanner = Scanner.new(@config)
|
|
17
|
+
@resource_scanner = ResourceScanner.new(@config)
|
|
18
|
+
@prompt_scanner = PromptScanner.new(@config)
|
|
16
19
|
@tools = {}
|
|
20
|
+
@resources = {}
|
|
21
|
+
@templates = {}
|
|
22
|
+
@prompts = {}
|
|
23
|
+
@subscriptions = {}
|
|
24
|
+
@log_level = 'info'
|
|
25
|
+
@icon = nil
|
|
26
|
+
@credential_cache = {}
|
|
17
27
|
end
|
|
18
28
|
|
|
19
|
-
# Load tools from the configured directories.
|
|
20
|
-
# handle_request directly (serve calls this
|
|
29
|
+
# Load tools (and resources/prompts) from the configured directories.
|
|
30
|
+
# Call this before using handle_request directly (serve calls this
|
|
31
|
+
# automatically).
|
|
21
32
|
def load_tools
|
|
22
33
|
@tools = @scanner.scan
|
|
23
|
-
|
|
34
|
+
@resource_scanner.scan
|
|
35
|
+
@resources = @resource_scanner.resources
|
|
36
|
+
@templates = @resource_scanner.templates
|
|
37
|
+
@prompt_scanner.scan
|
|
38
|
+
@prompts = @prompt_scanner.prompts
|
|
39
|
+
@icon = Config.resolve_icon(@config.icon)
|
|
40
|
+
|
|
41
|
+
resource_count = @resources.size + @templates.size
|
|
42
|
+
$stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
|
|
24
43
|
end
|
|
25
44
|
|
|
26
45
|
def serve
|
|
@@ -28,8 +47,17 @@ module ZeroMcp
|
|
|
28
47
|
$stderr.sync = true
|
|
29
48
|
$stdin.set_encoding('UTF-8')
|
|
30
49
|
$stdout.set_encoding('UTF-8')
|
|
50
|
+
|
|
31
51
|
@tools = @scanner.scan
|
|
32
|
-
|
|
52
|
+
@resource_scanner.scan
|
|
53
|
+
@resources = @resource_scanner.resources
|
|
54
|
+
@templates = @resource_scanner.templates
|
|
55
|
+
@prompt_scanner.scan
|
|
56
|
+
@prompts = @prompt_scanner.prompts
|
|
57
|
+
@icon = Config.resolve_icon(@config.icon)
|
|
58
|
+
|
|
59
|
+
resource_count = @resources.size + @templates.size
|
|
60
|
+
$stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
|
|
33
61
|
$stderr.puts "[zeromcp] stdio transport ready"
|
|
34
62
|
|
|
35
63
|
$stdin.each_line do |line|
|
|
@@ -59,7 +87,7 @@ module ZeroMcp
|
|
|
59
87
|
# Process a single JSON-RPC request hash and return a response hash.
|
|
60
88
|
# Returns nil for notifications that require no response.
|
|
61
89
|
#
|
|
62
|
-
# Note: tools must be loaded first via #serve or by calling
|
|
90
|
+
# Note: tools must be loaded first via #serve or by calling load_tools
|
|
63
91
|
# manually if using this method directly for HTTP integration.
|
|
64
92
|
#
|
|
65
93
|
# Usage:
|
|
@@ -69,49 +97,47 @@ module ZeroMcp
|
|
|
69
97
|
method = request['method']
|
|
70
98
|
params = request['params'] || {}
|
|
71
99
|
|
|
72
|
-
# Notifications (no id)
|
|
73
|
-
if id.nil?
|
|
100
|
+
# Notifications (no id)
|
|
101
|
+
if id.nil?
|
|
102
|
+
handle_notification(method, params)
|
|
74
103
|
return nil
|
|
75
104
|
end
|
|
76
105
|
|
|
77
106
|
case method
|
|
78
107
|
when 'initialize'
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
'result' => {
|
|
83
|
-
'protocolVersion' => '2024-11-05',
|
|
84
|
-
'capabilities' => {
|
|
85
|
-
'tools' => { 'listChanged' => true }
|
|
86
|
-
},
|
|
87
|
-
'serverInfo' => {
|
|
88
|
-
'name' => 'zeromcp',
|
|
89
|
-
'version' => '0.1.0'
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
108
|
+
handle_initialize(id, params)
|
|
109
|
+
when 'ping'
|
|
110
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
|
|
93
111
|
|
|
112
|
+
# Tools
|
|
94
113
|
when 'tools/list'
|
|
95
|
-
|
|
96
|
-
'jsonrpc' => '2.0',
|
|
97
|
-
'id' => id,
|
|
98
|
-
'result' => {
|
|
99
|
-
'tools' => build_tool_list
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
114
|
+
handle_tools_list(id, params)
|
|
103
115
|
when 'tools/call'
|
|
104
|
-
{
|
|
105
|
-
'jsonrpc' => '2.0',
|
|
106
|
-
'id' => id,
|
|
107
|
-
'result' => call_tool(params)
|
|
108
|
-
}
|
|
116
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => call_tool(params) }
|
|
109
117
|
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
# Resources
|
|
119
|
+
when 'resources/list'
|
|
120
|
+
handle_resources_list(id, params)
|
|
121
|
+
when 'resources/read'
|
|
122
|
+
handle_resources_read(id, params)
|
|
123
|
+
when 'resources/subscribe'
|
|
124
|
+
handle_resources_subscribe(id, params)
|
|
125
|
+
when 'resources/templates/list'
|
|
126
|
+
handle_resources_templates_list(id, params)
|
|
127
|
+
|
|
128
|
+
# Prompts
|
|
129
|
+
when 'prompts/list'
|
|
130
|
+
handle_prompts_list(id, params)
|
|
131
|
+
when 'prompts/get'
|
|
132
|
+
handle_prompts_get(id, params)
|
|
133
|
+
|
|
134
|
+
# Passthrough
|
|
135
|
+
when 'logging/setLevel'
|
|
136
|
+
handle_logging_set_level(id, params)
|
|
137
|
+
when 'completion/complete'
|
|
138
|
+
handle_completion_complete(id, params)
|
|
112
139
|
|
|
113
140
|
else
|
|
114
|
-
return nil if id.nil?
|
|
115
141
|
{
|
|
116
142
|
'jsonrpc' => '2.0',
|
|
117
143
|
'id' => id,
|
|
@@ -122,16 +148,259 @@ module ZeroMcp
|
|
|
122
148
|
|
|
123
149
|
private
|
|
124
150
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
151
|
+
# --- Notifications ---
|
|
152
|
+
|
|
153
|
+
def handle_notification(method, params)
|
|
154
|
+
case method
|
|
155
|
+
when 'notifications/initialized'
|
|
156
|
+
# no-op
|
|
157
|
+
when 'notifications/roots/list_changed'
|
|
158
|
+
# store roots if provided
|
|
159
|
+
if params.is_a?(Hash) && params['roots'].is_a?(Array)
|
|
160
|
+
@roots = params['roots']
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- Initialize ---
|
|
166
|
+
|
|
167
|
+
def handle_initialize(id, params)
|
|
168
|
+
capabilities = {
|
|
169
|
+
'tools' => { 'listChanged' => true }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if @resources.size > 0 || @templates.size > 0
|
|
173
|
+
capabilities['resources'] = { 'subscribe' => true, 'listChanged' => true }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if @prompts.size > 0
|
|
177
|
+
capabilities['prompts'] = { 'listChanged' => true }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
capabilities['logging'] = {}
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
'jsonrpc' => '2.0',
|
|
184
|
+
'id' => id,
|
|
185
|
+
'result' => {
|
|
186
|
+
'protocolVersion' => '2024-11-05',
|
|
187
|
+
'capabilities' => capabilities,
|
|
188
|
+
'serverInfo' => {
|
|
189
|
+
'name' => 'zeromcp',
|
|
190
|
+
'version' => '0.2.0'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# --- Tools ---
|
|
197
|
+
|
|
198
|
+
def handle_tools_list(id, params)
|
|
199
|
+
list = @tools.map do |name, tool|
|
|
200
|
+
entry = {
|
|
128
201
|
'name' => name,
|
|
129
202
|
'description' => tool.description,
|
|
130
203
|
'inputSchema' => tool.cached_schema
|
|
131
204
|
}
|
|
205
|
+
entry['icons'] = [{ 'uri' => @icon }] if @icon
|
|
206
|
+
entry
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
items, next_cursor = paginate(list, params['cursor'])
|
|
210
|
+
result = { 'tools' => items }
|
|
211
|
+
result['nextCursor'] = next_cursor if next_cursor
|
|
212
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# --- Resources ---
|
|
216
|
+
|
|
217
|
+
def handle_resources_list(id, params)
|
|
218
|
+
list = @resources.map do |_name, res|
|
|
219
|
+
entry = {
|
|
220
|
+
'uri' => res[:uri],
|
|
221
|
+
'name' => res[:name],
|
|
222
|
+
'description' => res[:description],
|
|
223
|
+
'mimeType' => res[:mime_type]
|
|
224
|
+
}
|
|
225
|
+
entry['icons'] = [{ 'uri' => @icon }] if @icon
|
|
226
|
+
entry
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
items, next_cursor = paginate(list, params['cursor'])
|
|
230
|
+
result = { 'resources' => items }
|
|
231
|
+
result['nextCursor'] = next_cursor if next_cursor
|
|
232
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def handle_resources_read(id, params)
|
|
236
|
+
uri = params.is_a?(Hash) ? params['uri'] : ''
|
|
237
|
+
uri ||= ''
|
|
238
|
+
|
|
239
|
+
# Check static/dynamic resources
|
|
240
|
+
@resources.each do |_name, res|
|
|
241
|
+
if res[:uri] == uri
|
|
242
|
+
begin
|
|
243
|
+
text = res[:read].call
|
|
244
|
+
return {
|
|
245
|
+
'jsonrpc' => '2.0',
|
|
246
|
+
'id' => id,
|
|
247
|
+
'result' => { 'contents' => [{ 'uri' => uri, 'mimeType' => res[:mime_type], 'text' => text }] }
|
|
248
|
+
}
|
|
249
|
+
rescue => e
|
|
250
|
+
return {
|
|
251
|
+
'jsonrpc' => '2.0',
|
|
252
|
+
'id' => id,
|
|
253
|
+
'error' => { 'code' => -32603, 'message' => "Error reading resource: #{e.message}" }
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Check templates
|
|
260
|
+
@templates.each do |_name, tmpl|
|
|
261
|
+
match = match_template(tmpl[:uri_template], uri)
|
|
262
|
+
if match
|
|
263
|
+
begin
|
|
264
|
+
text = tmpl[:read].call(match)
|
|
265
|
+
return {
|
|
266
|
+
'jsonrpc' => '2.0',
|
|
267
|
+
'id' => id,
|
|
268
|
+
'result' => { 'contents' => [{ 'uri' => uri, 'mimeType' => tmpl[:mime_type], 'text' => text }] }
|
|
269
|
+
}
|
|
270
|
+
rescue => e
|
|
271
|
+
return {
|
|
272
|
+
'jsonrpc' => '2.0',
|
|
273
|
+
'id' => id,
|
|
274
|
+
'error' => { 'code' => -32603, 'message' => "Error reading resource: #{e.message}" }
|
|
275
|
+
}
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'error' => { 'code' => -32002, 'message' => "Resource not found: #{uri}" } }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def handle_resources_subscribe(id, params)
|
|
284
|
+
uri = params.is_a?(Hash) ? params['uri'] : nil
|
|
285
|
+
@subscriptions[uri] = true if uri
|
|
286
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def handle_resources_templates_list(id, params)
|
|
290
|
+
list = @templates.map do |_name, tmpl|
|
|
291
|
+
entry = {
|
|
292
|
+
'uriTemplate' => tmpl[:uri_template],
|
|
293
|
+
'name' => tmpl[:name],
|
|
294
|
+
'description' => tmpl[:description],
|
|
295
|
+
'mimeType' => tmpl[:mime_type]
|
|
296
|
+
}
|
|
297
|
+
entry['icons'] = [{ 'uri' => @icon }] if @icon
|
|
298
|
+
entry
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
items, next_cursor = paginate(list, params['cursor'])
|
|
302
|
+
result = { 'resourceTemplates' => items }
|
|
303
|
+
result['nextCursor'] = next_cursor if next_cursor
|
|
304
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# --- Prompts ---
|
|
308
|
+
|
|
309
|
+
def handle_prompts_list(id, params)
|
|
310
|
+
list = @prompts.map do |_name, prompt|
|
|
311
|
+
entry = { 'name' => prompt[:name] }
|
|
312
|
+
entry['description'] = prompt[:description] if prompt[:description]
|
|
313
|
+
entry['arguments'] = prompt[:arguments] if prompt[:arguments]
|
|
314
|
+
entry['icons'] = [{ 'uri' => @icon }] if @icon
|
|
315
|
+
entry
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
items, next_cursor = paginate(list, params['cursor'])
|
|
319
|
+
result = { 'prompts' => items }
|
|
320
|
+
result['nextCursor'] = next_cursor if next_cursor
|
|
321
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def handle_prompts_get(id, params)
|
|
325
|
+
name = params.is_a?(Hash) ? params['name'] : ''
|
|
326
|
+
args = params.is_a?(Hash) ? (params['arguments'] || {}) : {}
|
|
327
|
+
|
|
328
|
+
prompt = @prompts[name]
|
|
329
|
+
unless prompt
|
|
330
|
+
return {
|
|
331
|
+
'jsonrpc' => '2.0',
|
|
332
|
+
'id' => id,
|
|
333
|
+
'error' => { 'code' => -32002, 'message' => "Prompt not found: #{name}" }
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
begin
|
|
338
|
+
messages = prompt[:render].call(args)
|
|
339
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => { 'messages' => messages } }
|
|
340
|
+
rescue => e
|
|
341
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'error' => { 'code' => -32603, 'message' => "Error rendering prompt: #{e.message}" } }
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# --- Passthrough ---
|
|
346
|
+
|
|
347
|
+
def handle_logging_set_level(id, params)
|
|
348
|
+
level = params.is_a?(Hash) ? params['level'] : nil
|
|
349
|
+
@log_level = level if level
|
|
350
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def handle_completion_complete(id, _params)
|
|
354
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => { 'completion' => { 'values' => [] } } }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# --- Pagination ---
|
|
358
|
+
|
|
359
|
+
def paginate(items, cursor)
|
|
360
|
+
page_size = @config.page_size
|
|
361
|
+
return [items, nil] if page_size <= 0
|
|
362
|
+
|
|
363
|
+
offset = cursor ? decode_cursor(cursor) : 0
|
|
364
|
+
slice = items[offset, page_size] || []
|
|
365
|
+
has_more = (offset + page_size) < items.size
|
|
366
|
+
next_cursor = has_more ? encode_cursor(offset + page_size) : nil
|
|
367
|
+
[slice, next_cursor]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def encode_cursor(offset)
|
|
371
|
+
Base64.strict_encode64(offset.to_s)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def decode_cursor(cursor)
|
|
375
|
+
decoded = Base64.decode64(cursor)
|
|
376
|
+
offset = decoded.to_i
|
|
377
|
+
offset < 0 ? 0 : offset
|
|
378
|
+
rescue
|
|
379
|
+
0
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# --- Template matching ---
|
|
383
|
+
|
|
384
|
+
def match_template(template, uri)
|
|
385
|
+
# Convert {param} placeholders to named capture groups
|
|
386
|
+
param_names = []
|
|
387
|
+
regex_str = template.gsub(/\{(\w+)\}/) do
|
|
388
|
+
param_names << $1
|
|
389
|
+
'([^/]+)'
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
match = uri.match(/\A#{regex_str}\z/)
|
|
393
|
+
return nil unless match
|
|
394
|
+
|
|
395
|
+
result = {}
|
|
396
|
+
param_names.each_with_index do |name, i|
|
|
397
|
+
result[name] = match[i + 1]
|
|
132
398
|
end
|
|
399
|
+
result
|
|
133
400
|
end
|
|
134
401
|
|
|
402
|
+
# --- Tool execution ---
|
|
403
|
+
|
|
135
404
|
def call_tool(params)
|
|
136
405
|
name = params.is_a?(Hash) ? params['name'] : nil
|
|
137
406
|
args = params.is_a?(Hash) ? (params['arguments'] || {}) : {}
|
|
@@ -173,15 +442,22 @@ module ZeroMcp
|
|
|
173
442
|
|
|
174
443
|
def _resolve_credentials(tool_name)
|
|
175
444
|
return nil if @config.credentials.empty?
|
|
176
|
-
# Match credential namespace from tool name prefix
|
|
177
445
|
@config.credentials.each do |ns, source|
|
|
178
446
|
if tool_name.start_with?("#{ns}_") || tool_name.start_with?("#{ns}#{@config.separator}")
|
|
179
|
-
return
|
|
447
|
+
return _resolve_credentials_for_ns(ns.to_s, source)
|
|
180
448
|
end
|
|
181
449
|
end
|
|
182
450
|
nil
|
|
183
451
|
end
|
|
184
452
|
|
|
453
|
+
def _resolve_credentials_for_ns(ns, source)
|
|
454
|
+
return _resolve_credential_source(source) unless @config.cache_credentials
|
|
455
|
+
return @credential_cache[ns] if @credential_cache.key?(ns)
|
|
456
|
+
creds = _resolve_credential_source(source)
|
|
457
|
+
@credential_cache[ns] = creds
|
|
458
|
+
creds
|
|
459
|
+
end
|
|
460
|
+
|
|
185
461
|
def _resolve_credential_source(source)
|
|
186
462
|
source = source.transform_keys(&:to_s) if source.is_a?(Hash)
|
|
187
463
|
if source['env']
|
data/zeromcp.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zeromcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Antidrift
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Drop tool files in a directory, get a working MCP server. Zero boilerplate.
|
|
14
14
|
email: hello@probeo.io
|