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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41b858d5fbd0613813087cb8ddb4af7f37ca0e14650dbd754937c8dd7450f5c6
4
- data.tar.gz: 9a33ac859956b5dd640654a81e83c22be566216c415812e0d5f70444113ebe3f
3
+ metadata.gz: da354f1e655ec8a11fdab32fe614424855447046b47087bcfe06708f9a7fedcd
4
+ data.tar.gz: c6ec0cf498da41c890302f96fbfa337b23c081ec81185595e9c5a1926832f5a4
5
5
  SHA512:
6
- metadata.gz: e0643b96c9e9c5e44bc67bf19bff9a564d947109a95ad1d1abf3d0d1d3a7fabc12e6333fca931096a84400b917a56c019aada8d7df18141adc03e2f44504e9c4
7
- data.tar.gz: c980ba8e71e11bddfbfaa006ceda4a359c9fa5c05008781fa2876998bfe8f13bf0d7c34b4e23673fac6a5a2fc953043e45cebaa7ce2e2f5bbfc3313846454161
6
+ metadata.gz: a623e6e4be5051688e21f6b4d5d3633305b8ce8666778634348d0edc3e1012afe0633fdce2b3f9a22fce625d19a98fdfb2fb1592fd06743e1db1b8cfa6332096
7
+ data.tar.gz: 79182a97581d16e9f5c75607641326db7cb01abc28a6683aee36ae27f2f028324298674bd903a800678b21d0b726174b4e63ff48fd346987bc5f15cc7f599989
@@ -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, :separator, :logging, :bypass_permissions, :execute_timeout
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
@@ -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
@@ -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. Call this before using
20
- # handle_request directly (serve calls this automatically).
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
- $stderr.puts "[zeromcp] #{@tools.size} tool(s) loaded"
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
- $stderr.puts "[zeromcp] #{@tools.size} tool(s) loaded"
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 scanner.scan
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) for known notification methods
73
- if id.nil? && method == 'notifications/initialized'
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
- 'jsonrpc' => '2.0',
81
- 'id' => id,
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
- when 'ping'
111
- { 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
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
- def build_tool_list
126
- @tools.map do |name, tool|
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' => Schema.to_json_schema(tool.input)
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
- schema = Schema.to_json_schema(tool.input)
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'zeromcp'
5
- s.version = '0.1.0'
5
+ s.version = '0.2.0'
6
6
  s.summary = 'Zero-config MCP runtime'
7
7
  s.description = 'Drop tool files in a directory, get a working MCP server. Zero boilerplate.'
8
8
  s.authors = ['Antidrift']
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.1.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-06 00:00:00.000000000 Z
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