zeromcp 0.1.0 → 0.2.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 +4 -4
- data/lib/zeromcp/config.rb +44 -1
- data/lib/zeromcp/scanner.rb +239 -0
- data/lib/zeromcp/server.rb +310 -43
- data/lib/zeromcp/tool.rb +4 -1
- 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: da354f1e655ec8a11fdab32fe614424855447046b47087bcfe06708f9a7fedcd
|
|
4
|
+
data.tar.gz: c6ec0cf498da41c890302f96fbfa337b23c081ec81185595e9c5a1926832f5a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a623e6e4be5051688e21f6b4d5d3633305b8ce8666778634348d0edc3e1012afe0633fdce2b3f9a22fce625d19a98fdfb2fb1592fd06743e1db1b8cfa6332096
|
|
7
|
+
data.tar.gz: 79182a97581d16e9f5c75607641326db7cb01abc28a6683aee36ae27f2f028324298674bd903a800678b21d0b726174b4e63ff48fd346987bc5f15cc7f599989
|
data/lib/zeromcp/config.rb
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
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'] || {}
|
|
17
27
|
@namespacing = opts[:namespacing] || opts['namespacing'] || {}
|
|
28
|
+
@page_size = opts[:page_size] || opts['page_size'] || 0
|
|
29
|
+
@icon = opts[:icon] || opts['icon']
|
|
18
30
|
end
|
|
19
31
|
|
|
20
32
|
attr_reader :credentials, :namespacing
|
|
@@ -29,5 +41,36 @@ module ZeroMcp
|
|
|
29
41
|
rescue JSON::ParserError
|
|
30
42
|
new
|
|
31
43
|
end
|
|
44
|
+
|
|
45
|
+
# Resolve an icon config value to a data URI. Supports:
|
|
46
|
+
# - data: URIs (returned as-is)
|
|
47
|
+
# - Local file paths (read and base64 encode)
|
|
48
|
+
# Returns nil on failure or if icon is not set.
|
|
49
|
+
def self.resolve_icon(icon)
|
|
50
|
+
return nil if icon.nil? || icon.empty?
|
|
51
|
+
return icon if icon.start_with?('data:')
|
|
52
|
+
|
|
53
|
+
# File path
|
|
54
|
+
path = File.expand_path(icon)
|
|
55
|
+
return nil unless File.exist?(path)
|
|
56
|
+
|
|
57
|
+
ext = File.extname(path).downcase
|
|
58
|
+
mime = ICON_MIME[ext] || 'image/png'
|
|
59
|
+
data = File.binread(path)
|
|
60
|
+
"data:#{mime};base64,#{Base64.strict_encode64(data)}"
|
|
61
|
+
rescue => e
|
|
62
|
+
$stderr.puts "[zeromcp] Warning: failed to read icon file #{icon}: #{e.message}"
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ICON_MIME = {
|
|
67
|
+
'.png' => 'image/png',
|
|
68
|
+
'.jpg' => 'image/jpeg',
|
|
69
|
+
'.jpeg' => 'image/jpeg',
|
|
70
|
+
'.gif' => 'image/gif',
|
|
71
|
+
'.svg' => 'image/svg+xml',
|
|
72
|
+
'.ico' => 'image/x-icon',
|
|
73
|
+
'.webp' => 'image/webp'
|
|
74
|
+
}.freeze
|
|
32
75
|
end
|
|
33
76
|
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,31 @@ 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
|
|
17
26
|
end
|
|
18
27
|
|
|
19
|
-
# Load tools from the configured directories.
|
|
20
|
-
# handle_request directly (serve calls this
|
|
28
|
+
# Load tools (and resources/prompts) from the configured directories.
|
|
29
|
+
# Call this before using handle_request directly (serve calls this
|
|
30
|
+
# automatically).
|
|
21
31
|
def load_tools
|
|
22
32
|
@tools = @scanner.scan
|
|
23
|
-
|
|
33
|
+
@resource_scanner.scan
|
|
34
|
+
@resources = @resource_scanner.resources
|
|
35
|
+
@templates = @resource_scanner.templates
|
|
36
|
+
@prompt_scanner.scan
|
|
37
|
+
@prompts = @prompt_scanner.prompts
|
|
38
|
+
@icon = Config.resolve_icon(@config.icon)
|
|
39
|
+
|
|
40
|
+
resource_count = @resources.size + @templates.size
|
|
41
|
+
$stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
|
|
24
42
|
end
|
|
25
43
|
|
|
26
44
|
def serve
|
|
@@ -28,8 +46,17 @@ module ZeroMcp
|
|
|
28
46
|
$stderr.sync = true
|
|
29
47
|
$stdin.set_encoding('UTF-8')
|
|
30
48
|
$stdout.set_encoding('UTF-8')
|
|
49
|
+
|
|
31
50
|
@tools = @scanner.scan
|
|
32
|
-
|
|
51
|
+
@resource_scanner.scan
|
|
52
|
+
@resources = @resource_scanner.resources
|
|
53
|
+
@templates = @resource_scanner.templates
|
|
54
|
+
@prompt_scanner.scan
|
|
55
|
+
@prompts = @prompt_scanner.prompts
|
|
56
|
+
@icon = Config.resolve_icon(@config.icon)
|
|
57
|
+
|
|
58
|
+
resource_count = @resources.size + @templates.size
|
|
59
|
+
$stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
|
|
33
60
|
$stderr.puts "[zeromcp] stdio transport ready"
|
|
34
61
|
|
|
35
62
|
$stdin.each_line do |line|
|
|
@@ -59,7 +86,7 @@ module ZeroMcp
|
|
|
59
86
|
# Process a single JSON-RPC request hash and return a response hash.
|
|
60
87
|
# Returns nil for notifications that require no response.
|
|
61
88
|
#
|
|
62
|
-
# Note: tools must be loaded first via #serve or by calling
|
|
89
|
+
# Note: tools must be loaded first via #serve or by calling load_tools
|
|
63
90
|
# manually if using this method directly for HTTP integration.
|
|
64
91
|
#
|
|
65
92
|
# Usage:
|
|
@@ -69,49 +96,47 @@ module ZeroMcp
|
|
|
69
96
|
method = request['method']
|
|
70
97
|
params = request['params'] || {}
|
|
71
98
|
|
|
72
|
-
# Notifications (no id)
|
|
73
|
-
if id.nil?
|
|
99
|
+
# Notifications (no id)
|
|
100
|
+
if id.nil?
|
|
101
|
+
handle_notification(method, params)
|
|
74
102
|
return nil
|
|
75
103
|
end
|
|
76
104
|
|
|
77
105
|
case method
|
|
78
106
|
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
|
-
}
|
|
107
|
+
handle_initialize(id, params)
|
|
108
|
+
when 'ping'
|
|
109
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
|
|
93
110
|
|
|
111
|
+
# Tools
|
|
94
112
|
when 'tools/list'
|
|
95
|
-
|
|
96
|
-
'jsonrpc' => '2.0',
|
|
97
|
-
'id' => id,
|
|
98
|
-
'result' => {
|
|
99
|
-
'tools' => build_tool_list
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
113
|
+
handle_tools_list(id, params)
|
|
103
114
|
when 'tools/call'
|
|
104
|
-
{
|
|
105
|
-
'jsonrpc' => '2.0',
|
|
106
|
-
'id' => id,
|
|
107
|
-
'result' => call_tool(params)
|
|
108
|
-
}
|
|
115
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => call_tool(params) }
|
|
109
116
|
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
# Resources
|
|
118
|
+
when 'resources/list'
|
|
119
|
+
handle_resources_list(id, params)
|
|
120
|
+
when 'resources/read'
|
|
121
|
+
handle_resources_read(id, params)
|
|
122
|
+
when 'resources/subscribe'
|
|
123
|
+
handle_resources_subscribe(id, params)
|
|
124
|
+
when 'resources/templates/list'
|
|
125
|
+
handle_resources_templates_list(id, params)
|
|
126
|
+
|
|
127
|
+
# Prompts
|
|
128
|
+
when 'prompts/list'
|
|
129
|
+
handle_prompts_list(id, params)
|
|
130
|
+
when 'prompts/get'
|
|
131
|
+
handle_prompts_get(id, params)
|
|
132
|
+
|
|
133
|
+
# Passthrough
|
|
134
|
+
when 'logging/setLevel'
|
|
135
|
+
handle_logging_set_level(id, params)
|
|
136
|
+
when 'completion/complete'
|
|
137
|
+
handle_completion_complete(id, params)
|
|
112
138
|
|
|
113
139
|
else
|
|
114
|
-
return nil if id.nil?
|
|
115
140
|
{
|
|
116
141
|
'jsonrpc' => '2.0',
|
|
117
142
|
'id' => id,
|
|
@@ -122,16 +147,259 @@ module ZeroMcp
|
|
|
122
147
|
|
|
123
148
|
private
|
|
124
149
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
150
|
+
# --- Notifications ---
|
|
151
|
+
|
|
152
|
+
def handle_notification(method, params)
|
|
153
|
+
case method
|
|
154
|
+
when 'notifications/initialized'
|
|
155
|
+
# no-op
|
|
156
|
+
when 'notifications/roots/list_changed'
|
|
157
|
+
# store roots if provided
|
|
158
|
+
if params.is_a?(Hash) && params['roots'].is_a?(Array)
|
|
159
|
+
@roots = params['roots']
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- Initialize ---
|
|
165
|
+
|
|
166
|
+
def handle_initialize(id, params)
|
|
167
|
+
capabilities = {
|
|
168
|
+
'tools' => { 'listChanged' => true }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if @resources.size > 0 || @templates.size > 0
|
|
172
|
+
capabilities['resources'] = { 'subscribe' => true, 'listChanged' => true }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
if @prompts.size > 0
|
|
176
|
+
capabilities['prompts'] = { 'listChanged' => true }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
capabilities['logging'] = {}
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
'jsonrpc' => '2.0',
|
|
183
|
+
'id' => id,
|
|
184
|
+
'result' => {
|
|
185
|
+
'protocolVersion' => '2024-11-05',
|
|
186
|
+
'capabilities' => capabilities,
|
|
187
|
+
'serverInfo' => {
|
|
188
|
+
'name' => 'zeromcp',
|
|
189
|
+
'version' => '0.2.0'
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# --- Tools ---
|
|
196
|
+
|
|
197
|
+
def handle_tools_list(id, params)
|
|
198
|
+
list = @tools.map do |name, tool|
|
|
199
|
+
entry = {
|
|
128
200
|
'name' => name,
|
|
129
201
|
'description' => tool.description,
|
|
130
|
-
'inputSchema' =>
|
|
202
|
+
'inputSchema' => tool.cached_schema
|
|
203
|
+
}
|
|
204
|
+
entry['icons'] = [{ 'uri' => @icon }] if @icon
|
|
205
|
+
entry
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
items, next_cursor = paginate(list, params['cursor'])
|
|
209
|
+
result = { 'tools' => items }
|
|
210
|
+
result['nextCursor'] = next_cursor if next_cursor
|
|
211
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# --- Resources ---
|
|
215
|
+
|
|
216
|
+
def handle_resources_list(id, params)
|
|
217
|
+
list = @resources.map do |_name, res|
|
|
218
|
+
entry = {
|
|
219
|
+
'uri' => res[:uri],
|
|
220
|
+
'name' => res[:name],
|
|
221
|
+
'description' => res[:description],
|
|
222
|
+
'mimeType' => res[:mime_type]
|
|
223
|
+
}
|
|
224
|
+
entry['icons'] = [{ 'uri' => @icon }] if @icon
|
|
225
|
+
entry
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
items, next_cursor = paginate(list, params['cursor'])
|
|
229
|
+
result = { 'resources' => items }
|
|
230
|
+
result['nextCursor'] = next_cursor if next_cursor
|
|
231
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def handle_resources_read(id, params)
|
|
235
|
+
uri = params.is_a?(Hash) ? params['uri'] : ''
|
|
236
|
+
uri ||= ''
|
|
237
|
+
|
|
238
|
+
# Check static/dynamic resources
|
|
239
|
+
@resources.each do |_name, res|
|
|
240
|
+
if res[:uri] == uri
|
|
241
|
+
begin
|
|
242
|
+
text = res[:read].call
|
|
243
|
+
return {
|
|
244
|
+
'jsonrpc' => '2.0',
|
|
245
|
+
'id' => id,
|
|
246
|
+
'result' => { 'contents' => [{ 'uri' => uri, 'mimeType' => res[:mime_type], 'text' => text }] }
|
|
247
|
+
}
|
|
248
|
+
rescue => e
|
|
249
|
+
return {
|
|
250
|
+
'jsonrpc' => '2.0',
|
|
251
|
+
'id' => id,
|
|
252
|
+
'error' => { 'code' => -32603, 'message' => "Error reading resource: #{e.message}" }
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Check templates
|
|
259
|
+
@templates.each do |_name, tmpl|
|
|
260
|
+
match = match_template(tmpl[:uri_template], uri)
|
|
261
|
+
if match
|
|
262
|
+
begin
|
|
263
|
+
text = tmpl[:read].call(match)
|
|
264
|
+
return {
|
|
265
|
+
'jsonrpc' => '2.0',
|
|
266
|
+
'id' => id,
|
|
267
|
+
'result' => { 'contents' => [{ 'uri' => uri, 'mimeType' => tmpl[:mime_type], 'text' => text }] }
|
|
268
|
+
}
|
|
269
|
+
rescue => e
|
|
270
|
+
return {
|
|
271
|
+
'jsonrpc' => '2.0',
|
|
272
|
+
'id' => id,
|
|
273
|
+
'error' => { 'code' => -32603, 'message' => "Error reading resource: #{e.message}" }
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'error' => { 'code' => -32002, 'message' => "Resource not found: #{uri}" } }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def handle_resources_subscribe(id, params)
|
|
283
|
+
uri = params.is_a?(Hash) ? params['uri'] : nil
|
|
284
|
+
@subscriptions[uri] = true if uri
|
|
285
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def handle_resources_templates_list(id, params)
|
|
289
|
+
list = @templates.map do |_name, tmpl|
|
|
290
|
+
entry = {
|
|
291
|
+
'uriTemplate' => tmpl[:uri_template],
|
|
292
|
+
'name' => tmpl[:name],
|
|
293
|
+
'description' => tmpl[:description],
|
|
294
|
+
'mimeType' => tmpl[:mime_type]
|
|
131
295
|
}
|
|
296
|
+
entry['icons'] = [{ 'uri' => @icon }] if @icon
|
|
297
|
+
entry
|
|
132
298
|
end
|
|
299
|
+
|
|
300
|
+
items, next_cursor = paginate(list, params['cursor'])
|
|
301
|
+
result = { 'resourceTemplates' => items }
|
|
302
|
+
result['nextCursor'] = next_cursor if next_cursor
|
|
303
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# --- Prompts ---
|
|
307
|
+
|
|
308
|
+
def handle_prompts_list(id, params)
|
|
309
|
+
list = @prompts.map do |_name, prompt|
|
|
310
|
+
entry = { 'name' => prompt[:name] }
|
|
311
|
+
entry['description'] = prompt[:description] if prompt[:description]
|
|
312
|
+
entry['arguments'] = prompt[:arguments] if prompt[:arguments]
|
|
313
|
+
entry['icons'] = [{ 'uri' => @icon }] if @icon
|
|
314
|
+
entry
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
items, next_cursor = paginate(list, params['cursor'])
|
|
318
|
+
result = { 'prompts' => items }
|
|
319
|
+
result['nextCursor'] = next_cursor if next_cursor
|
|
320
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
133
321
|
end
|
|
134
322
|
|
|
323
|
+
def handle_prompts_get(id, params)
|
|
324
|
+
name = params.is_a?(Hash) ? params['name'] : ''
|
|
325
|
+
args = params.is_a?(Hash) ? (params['arguments'] || {}) : {}
|
|
326
|
+
|
|
327
|
+
prompt = @prompts[name]
|
|
328
|
+
unless prompt
|
|
329
|
+
return {
|
|
330
|
+
'jsonrpc' => '2.0',
|
|
331
|
+
'id' => id,
|
|
332
|
+
'error' => { 'code' => -32002, 'message' => "Prompt not found: #{name}" }
|
|
333
|
+
}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
begin
|
|
337
|
+
messages = prompt[:render].call(args)
|
|
338
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => { 'messages' => messages } }
|
|
339
|
+
rescue => e
|
|
340
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'error' => { 'code' => -32603, 'message' => "Error rendering prompt: #{e.message}" } }
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# --- Passthrough ---
|
|
345
|
+
|
|
346
|
+
def handle_logging_set_level(id, params)
|
|
347
|
+
level = params.is_a?(Hash) ? params['level'] : nil
|
|
348
|
+
@log_level = level if level
|
|
349
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def handle_completion_complete(id, _params)
|
|
353
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => { 'completion' => { 'values' => [] } } }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# --- Pagination ---
|
|
357
|
+
|
|
358
|
+
def paginate(items, cursor)
|
|
359
|
+
page_size = @config.page_size
|
|
360
|
+
return [items, nil] if page_size <= 0
|
|
361
|
+
|
|
362
|
+
offset = cursor ? decode_cursor(cursor) : 0
|
|
363
|
+
slice = items[offset, page_size] || []
|
|
364
|
+
has_more = (offset + page_size) < items.size
|
|
365
|
+
next_cursor = has_more ? encode_cursor(offset + page_size) : nil
|
|
366
|
+
[slice, next_cursor]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def encode_cursor(offset)
|
|
370
|
+
Base64.strict_encode64(offset.to_s)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def decode_cursor(cursor)
|
|
374
|
+
decoded = Base64.decode64(cursor)
|
|
375
|
+
offset = decoded.to_i
|
|
376
|
+
offset < 0 ? 0 : offset
|
|
377
|
+
rescue
|
|
378
|
+
0
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# --- Template matching ---
|
|
382
|
+
|
|
383
|
+
def match_template(template, uri)
|
|
384
|
+
# Convert {param} placeholders to named capture groups
|
|
385
|
+
param_names = []
|
|
386
|
+
regex_str = template.gsub(/\{(\w+)\}/) do
|
|
387
|
+
param_names << $1
|
|
388
|
+
'([^/]+)'
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
match = uri.match(/\A#{regex_str}\z/)
|
|
392
|
+
return nil unless match
|
|
393
|
+
|
|
394
|
+
result = {}
|
|
395
|
+
param_names.each_with_index do |name, i|
|
|
396
|
+
result[name] = match[i + 1]
|
|
397
|
+
end
|
|
398
|
+
result
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# --- Tool execution ---
|
|
402
|
+
|
|
135
403
|
def call_tool(params)
|
|
136
404
|
name = params.is_a?(Hash) ? params['name'] : nil
|
|
137
405
|
args = params.is_a?(Hash) ? (params['arguments'] || {}) : {}
|
|
@@ -145,8 +413,7 @@ module ZeroMcp
|
|
|
145
413
|
}
|
|
146
414
|
end
|
|
147
415
|
|
|
148
|
-
|
|
149
|
-
errors = Schema.validate(args, schema)
|
|
416
|
+
errors = Schema.validate(args, tool.cached_schema)
|
|
150
417
|
if errors.any?
|
|
151
418
|
return {
|
|
152
419
|
'content' => [{ 'type' => 'text', 'text' => "Validation errors:\n#{errors.join("\n")}" }],
|
data/lib/zeromcp/tool.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'schema'
|
|
4
|
+
|
|
3
5
|
module ZeroMcp
|
|
4
6
|
class Tool
|
|
5
|
-
attr_reader :name, :description, :input, :permissions, :execute_block
|
|
7
|
+
attr_reader :name, :description, :input, :permissions, :execute_block, :cached_schema
|
|
6
8
|
|
|
7
9
|
def initialize(name:, description: '', input: {}, permissions: {}, &block)
|
|
8
10
|
@name = name
|
|
@@ -10,6 +12,7 @@ module ZeroMcp
|
|
|
10
12
|
@input = input
|
|
11
13
|
@permissions = permissions
|
|
12
14
|
@execute_block = block
|
|
15
|
+
@cached_schema = Schema.to_json_schema(@input)
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def call(args, ctx = {})
|
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.0
|
|
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-08 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
|