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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbe9bf54912165bb33c8934830b2e743a9ac0b7248db1bff952c055ae98da075
4
- data.tar.gz: 8507519d2addb779518363c2630032b3e89e72ea1ab4413c976c5d424928e0ec
3
+ metadata.gz: b2e5b203cdc7f03afff0cf6ce507e9480455b3f16e469222d5934af9351ffa9a
4
+ data.tar.gz: 8e968d22d810a2f25962eb5de0f242438f3ea4359c40cee4616398280695c6f8
5
5
  SHA512:
6
- metadata.gz: e59df47759f27cd5f832b55ebd9139e7874732186ef188ef7eae2d2150284f0d055ec6c1beae462e09ad54b396b8fd82ff52a54c5680375fc9e9fd7384b520bd
7
- data.tar.gz: 7958981ea82048324f9a5a81bb3ba9d483a9f5b0d9ae59877e150767af59712ac3aa2d99fa5c2a91a586957441661f99a2154e1c88f07458a85a731db583631f
6
+ metadata.gz: b5af7807f278f6430cd65fad75104d90536fbe5c840a2dfce3b7c125415940d65f4166e420f06b085c1ff6fe60c90a525773956e4e341c3b20098258227296a5
7
+ data.tar.gz: 3c0c7c8350efedaf3bf7ec4342e85f817b6da45e6668d6aace1553ef98edc478143db42acc4652b119a179c7b4580ddef29c54226457d03b3ed88d4582e6bd09
@@ -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, :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'] || {}
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
@@ -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,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. Call this before using
20
- # handle_request directly (serve calls this automatically).
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
- $stderr.puts "[zeromcp] #{@tools.size} tool(s) loaded"
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
- $stderr.puts "[zeromcp] #{@tools.size} tool(s) loaded"
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 scanner.scan
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) for known notification methods
73
- if id.nil? && method == 'notifications/initialized'
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
- '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
- }
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
- when 'ping'
111
- { 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
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
- def build_tool_list
126
- @tools.map do |name, tool|
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 _resolve_credential_source(source)
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'zeromcp'
5
- s.version = '0.1.1'
5
+ s.version = '0.2.2'
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.1
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-07 00:00:00.000000000 Z
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