zeromcp 0.2.0 → 0.3.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: da354f1e655ec8a11fdab32fe614424855447046b47087bcfe06708f9a7fedcd
4
- data.tar.gz: c6ec0cf498da41c890302f96fbfa337b23c081ec81185595e9c5a1926832f5a4
3
+ metadata.gz: aa95cc5fe08e2a54b9034a2248b978e1a01919bd3cb0249f444031e070f0dcd1
4
+ data.tar.gz: ae4cd9f4405f8b9786f0bf5e238898b8260d5d37b5a8aac9bdd96ac3ae02876a
5
5
  SHA512:
6
- metadata.gz: a623e6e4be5051688e21f6b4d5d3633305b8ce8666778634348d0edc3e1012afe0633fdce2b3f9a22fce625d19a98fdfb2fb1592fd06743e1db1b8cfa6332096
7
- data.tar.gz: 79182a97581d16e9f5c75607641326db7cb01abc28a6683aee36ae27f2f028324298674bd903a800678b21d0b726174b4e63ff48fd346987bc5f15cc7f599989
6
+ metadata.gz: 38b41336ef5617f74cf599c79cb0fd52f872eba7c912825c74020a3026cf8c8e360a1b79ac2f12a612e73cb657e2a2d240a4fed709012796c430990100ddd26f
7
+ data.tar.gz: b502b28ab22e0666937aeb772818d6bf8739671c4b274b03acec2307652d64830aa37ee7611094405be58de2c9d134ed67a3ed507241cb8615ea6fe3c90d7def
data/README.md CHANGED
@@ -43,6 +43,11 @@ ZeroMCP doesn't own the HTTP layer. You bring your own framework; ZeroMCP gives
43
43
  ```ruby
44
44
  require 'sinatra'
45
45
  require 'json'
46
+ require_relative 'lib/zeromcp'
47
+
48
+ config = ZeroMcp::Config.load
49
+ server = ZeroMcp::Server.new(config)
50
+ server.load_tools
46
51
 
47
52
  post '/mcp' do
48
53
  request_body = JSON.parse(request.body.read)
@@ -66,7 +71,7 @@ end
66
71
 
67
72
  ```sh
68
73
  gem build zeromcp.gemspec
69
- gem install zeromcp-0.1.0.gem
74
+ gem install zeromcp-0.2.2.gem
70
75
  ```
71
76
 
72
77
  ## Sandbox
@@ -99,5 +104,5 @@ tools/
99
104
  ## Testing
100
105
 
101
106
  ```sh
102
- ruby -I lib -I test -e 'Dir["test/**/*_test.rb"].each { |f| require_relative f }'
107
+ ruby -I lib -I test -e 'Dir["test/**/test_*.rb"].each { |f| require_relative f }'
103
108
  ```
@@ -7,7 +7,7 @@ module ZeroMcp
7
7
  class Config
8
8
  attr_reader :tools_dir, :resources_dir, :prompts_dir,
9
9
  :separator, :logging, :bypass_permissions, :execute_timeout,
10
- :page_size, :icon
10
+ :page_size, :icon, :title
11
11
 
12
12
  def initialize(opts = {})
13
13
  tools = opts[:tools_dir] || opts['tools'] || './tools'
@@ -24,12 +24,14 @@ module ZeroMcp
24
24
  @bypass_permissions = opts[:bypass_permissions] || opts['bypass_permissions'] || false
25
25
  @execute_timeout = opts[:execute_timeout] || opts['execute_timeout'] || 30 # seconds
26
26
  @credentials = opts[:credentials] || opts['credentials'] || {}
27
+ @cache_credentials = opts.key?(:cache_credentials) ? opts[:cache_credentials] : (opts.key?('cache_credentials') ? opts['cache_credentials'] : true)
27
28
  @namespacing = opts[:namespacing] || opts['namespacing'] || {}
28
29
  @page_size = opts[:page_size] || opts['page_size'] || 0
29
30
  @icon = opts[:icon] || opts['icon']
31
+ @title = opts[:title] || opts['title'] || 'ZeroMCP'
30
32
  end
31
33
 
32
- attr_reader :credentials, :namespacing
34
+ attr_reader :credentials, :cache_credentials, :namespacing
33
35
 
34
36
  def self.load(path = nil)
35
37
  path ||= File.join(Dir.pwd, 'zeromcp.config.json')
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ZeroMcp
6
+ module Credentials
7
+ module_function
8
+
9
+ def resolve(tool_name, config, cache: nil)
10
+ return nil if config.credentials.empty?
11
+ config.credentials.each do |ns, source|
12
+ if tool_name.start_with?("#{ns}_") || tool_name.start_with?("#{ns}#{config.separator}")
13
+ return resolve_for_ns(ns.to_s, source, config, cache)
14
+ end
15
+ end
16
+ nil
17
+ end
18
+
19
+ def resolve_for_ns(ns, source, config, cache)
20
+ return resolve_source(source) unless config.cache_credentials
21
+ return cache[ns] if cache && cache.key?(ns)
22
+ creds = resolve_source(source)
23
+ cache[ns] = creds if cache
24
+ creds
25
+ end
26
+
27
+ def resolve_source(source)
28
+ source = source.transform_keys(&:to_s) if source.is_a?(Hash)
29
+ if source['env']
30
+ val = ENV[source['env']]
31
+ return nil if val.nil? || val.empty?
32
+ begin; return JSON.parse(val); rescue; return val; end
33
+ end
34
+ if source['file']
35
+ path = File.expand_path(source['file'])
36
+ return nil unless File.exist?(path)
37
+ val = File.read(path).strip
38
+ begin; return JSON.parse(val); rescue; return val; end
39
+ end
40
+ nil
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'schema'
4
+
5
+ module ZeroMcp
6
+ module OpenApi
7
+ module_function
8
+
9
+ def build(tools, config)
10
+ paths = {}
11
+
12
+ tools.each do |_name, tool|
13
+ next unless tool.route.is_a?(Hash)
14
+
15
+ route_method = tool.route_method
16
+ route_path = tool.route_path
17
+
18
+ # Extract :param names from path, convert to {param} for OpenAPI
19
+ path_param_names = route_path.scan(/:([A-Za-z_][A-Za-z0-9_]*)/).flatten
20
+ openapi_path = route_path.gsub(/:([A-Za-z_][A-Za-z0-9_]*)/, '{\1}')
21
+
22
+ input = tool.input || {}
23
+ operation = {
24
+ 'operationId' => tool.name,
25
+ 'description' => tool.description || '',
26
+ 'responses' => {
27
+ '200' => { 'description' => 'Success' },
28
+ '500' => { 'description' => 'Error' }
29
+ }
30
+ }
31
+
32
+ if route_method == 'GET'
33
+ operation['parameters'] = build_parameters(input, path_param_names)
34
+ else
35
+ operation['requestBody'] = build_request_body(input, path_param_names)
36
+ unless path_param_names.empty?
37
+ operation['parameters'] = path_param_names.map do |name|
38
+ {
39
+ 'name' => name,
40
+ 'in' => 'path',
41
+ 'required' => true,
42
+ 'schema' => { 'type' => 'string' }
43
+ }
44
+ end
45
+ end
46
+ end
47
+
48
+ paths[openapi_path] ||= {}
49
+ paths[openapi_path][route_method.downcase] = operation
50
+ end
51
+
52
+ {
53
+ 'openapi' => '3.0.0',
54
+ 'info' => { 'title' => config.title, 'version' => '0.5.0' },
55
+ 'paths' => paths
56
+ }
57
+ end
58
+
59
+ def build_parameters(input, path_param_names)
60
+ params = []
61
+
62
+ # Path params first (preserve order from path)
63
+ path_param_names.each do |name|
64
+ spec = field_to_openapi_schema(input[name] || input[name.to_sym])
65
+ params << {
66
+ 'name' => name,
67
+ 'in' => 'path',
68
+ 'required' => true,
69
+ 'schema' => spec
70
+ }
71
+ end
72
+
73
+ # Remaining fields as query params
74
+ input.each do |key, value|
75
+ key_s = key.to_s
76
+ next if path_param_names.include?(key_s)
77
+
78
+ spec = field_to_openapi_schema(value)
79
+ optional = value.is_a?(Hash) && (value[:optional] || value['optional'])
80
+ params << {
81
+ 'name' => key_s,
82
+ 'in' => 'query',
83
+ 'required' => !optional,
84
+ 'schema' => spec
85
+ }
86
+ end
87
+
88
+ params
89
+ end
90
+
91
+ def build_request_body(input, path_param_names)
92
+ body_input = input.reject { |key, _| path_param_names.include?(key.to_s) }
93
+ schema = Schema.to_json_schema(body_input)
94
+ {
95
+ 'required' => true,
96
+ 'content' => {
97
+ 'application/json' => { 'schema' => schema }
98
+ }
99
+ }
100
+ end
101
+
102
+ def field_to_openapi_schema(value)
103
+ return { 'type' => 'string' } if value.nil?
104
+
105
+ if value.is_a?(String)
106
+ Schema::TYPE_MAP[value] || { 'type' => 'string' }
107
+ elsif value.is_a?(Hash)
108
+ type = value[:type] || value['type']
109
+ mapped = Schema::TYPE_MAP[type.to_s] || { 'type' => 'string' }
110
+ spec = mapped.dup
111
+ desc = value[:description] || value['description']
112
+ spec['description'] = desc if desc
113
+ spec
114
+ else
115
+ { 'type' => 'string' }
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'server'
4
+ require_relative 'openapi'
5
+ require_relative 'credentials'
6
+
7
+ module ZeroMcp
8
+ module Registry
9
+ RouteDefinition = Struct.new(:name, :method, :path, :tool, keyword_init: true)
10
+
11
+ ToolRegistry = Struct.new(:routes, :openapi, :mcp, :config, :credential_cache, keyword_init: true) do
12
+ # Build a ready-to-use Context for `tool`, using the same config-driven
13
+ # credential/permission rules Server#call_tool already applies — so a
14
+ # caller invoking a routed tool directly (route.tool.call(args, ctx))
15
+ # doesn't have to hand-roll credential lookup.
16
+ def context_for(tool)
17
+ ZeroMcp::Context.new(
18
+ tool_name: tool.name,
19
+ permissions: tool.permissions,
20
+ bypass: config.bypass_permissions,
21
+ credentials: ZeroMcp::Credentials.resolve(tool.name, config, cache: credential_cache)
22
+ )
23
+ end
24
+ end
25
+
26
+ module_function
27
+
28
+ # tools: Hash[String, ZeroMcp::Tool] — already-constructed Tool
29
+ # instances, the same object Scanner#load_tool produces.
30
+ # Not raw hashes, and not file paths.
31
+ # config: optional ZeroMcp::Config. Defaults to ZeroMcp::Config.new
32
+ # (in-memory defaults, no zeromcp.config.json file read).
33
+ # When supplied, title:/execute_timeout: are ignored (the
34
+ # config already fully specifies them).
35
+ # title: OpenAPI info.title override. Ignored if config: is given.
36
+ # execute_timeout: default per-tool execute timeout in seconds. Ignored if
37
+ # config: is given.
38
+ def create(tools, config: nil, title: nil, execute_timeout: nil)
39
+ cfg = config || Config.new(title: title, execute_timeout: execute_timeout)
40
+ server = Server.new(cfg, tools: tools)
41
+ credential_cache = {}
42
+
43
+ routes = tools.each_with_object([]) do |(name, tool), acc|
44
+ next unless tool.route.is_a?(Hash)
45
+ acc << RouteDefinition.new(name: name, method: tool.route_method, path: tool.route_path, tool: tool)
46
+ end
47
+
48
+ ToolRegistry.new(
49
+ routes: routes,
50
+ openapi: OpenApi.build(tools, cfg),
51
+ mcp: ->(request) { server.handle_request(request) },
52
+ config: cfg,
53
+ credential_cache: credential_cache
54
+ )
55
+ end
56
+ end
57
+
58
+ # Top-level convenience, mirroring ZeroMcp.serve / ZeroMcp.serve_http.
59
+ def self.create_registry(tools, config: nil, title: nil, execute_timeout: nil)
60
+ Registry.create(tools, config: config, title: title, execute_timeout: execute_timeout)
61
+ end
62
+ end
@@ -57,7 +57,8 @@ module ZeroMcp
57
57
  name: name,
58
58
  description: tool_def[:description] || '',
59
59
  input: tool_def[:input] || {},
60
- permissions: tool_def[:permissions] || {}
60
+ permissions: tool_def[:permissions] || {},
61
+ route: tool_def[:route]
61
62
  ) { |args, ctx| tool_def[:execute].call(args, ctx) }
62
63
 
63
64
  $stderr.puts "[zeromcp] Loaded: #{name}"
@@ -103,10 +104,11 @@ module ZeroMcp
103
104
  @definition = {}
104
105
  end
105
106
 
106
- def tool(description: '', permissions: {}, input: {})
107
+ def tool(description: '', permissions: {}, input: {}, route: nil)
107
108
  @definition[:description] = description
108
109
  @definition[:permissions] = permissions
109
110
  @definition[:input] = input
111
+ @definition[:route] = route
110
112
  end
111
113
 
112
114
  def execute(&block)
@@ -3,26 +3,85 @@
3
3
  require 'json'
4
4
  require 'base64'
5
5
  require 'timeout'
6
+ require 'webrick'
6
7
  require_relative 'schema'
7
8
  require_relative 'config'
8
9
  require_relative 'tool'
9
10
  require_relative 'scanner'
10
11
  require_relative 'sandbox'
12
+ require_relative 'openapi'
13
+ require_relative 'credentials'
11
14
 
12
15
  module ZeroMcp
13
16
  class Server
14
- def initialize(config = nil)
17
+ def initialize(config = nil, tools: nil)
15
18
  @config = config || Config.load
16
19
  @scanner = Scanner.new(@config)
17
20
  @resource_scanner = ResourceScanner.new(@config)
18
21
  @prompt_scanner = PromptScanner.new(@config)
19
- @tools = {}
22
+ @tools = tools || {}
20
23
  @resources = {}
21
24
  @templates = {}
22
25
  @prompts = {}
23
26
  @subscriptions = {}
24
27
  @log_level = 'info'
25
- @icon = nil
28
+ @icon = tools ? Config.resolve_icon(@config.icon) : nil
29
+ @credential_cache = {}
30
+ end
31
+
32
+ # Start an HTTP server that exposes:
33
+ # POST /mcp — JSON-RPC over HTTP
34
+ # <method> <path> — per-tool route handlers (tools with a `route:` field)
35
+ #
36
+ # Usage: ZeroMcp::Server.new.serve_http(port: 3000)
37
+ def serve_http(port: 3000)
38
+ load_tools
39
+
40
+ http = WEBrick::HTTPServer.new(Port: port, Logger: WEBrick::Log.new($stderr), AccessLog: [])
41
+ $stderr.puts "[zeromcp] HTTP server listening on port #{port}"
42
+
43
+ # /mcp — JSON-RPC endpoint
44
+ http.mount_proc('/mcp') do |req, res|
45
+ begin
46
+ request = JSON.parse(req.body || '{}')
47
+ rescue JSON::ParserError
48
+ res.status = 400
49
+ res.content_type = 'application/json'
50
+ res.body = JSON.generate({ 'error' => 'Invalid JSON' })
51
+ next
52
+ end
53
+ response = handle_request(request)
54
+ res.content_type = 'application/json'
55
+ res.body = JSON.generate(response || {})
56
+ end
57
+
58
+ # /openapi.json — auto-generated OpenAPI 3.0 spec from routed tools
59
+ http.mount_proc('/openapi.json') do |_req, res|
60
+ res.content_type = 'application/json'
61
+ res.body = JSON.generate(build_openapi_spec)
62
+ end
63
+
64
+ # /docs — Swagger UI
65
+ http.mount_proc('/docs') do |_req, res|
66
+ res.content_type = 'text/html'
67
+ res.body = SWAGGER_UI_HTML
68
+ end
69
+
70
+ # /health — liveness check
71
+ http.mount_proc('/health') do |_req, res|
72
+ res.content_type = 'application/json'
73
+ res.body = JSON.generate({ 'ok' => true })
74
+ end
75
+
76
+ # Register per-tool HTTP routes
77
+ @tools.each do |_name, tool|
78
+ next unless tool.route.is_a?(Hash)
79
+ register_tool_route(http, tool, tool.route_method, tool.route_path)
80
+ end
81
+
82
+ trap('INT') { http.shutdown }
83
+ trap('TERM') { http.shutdown }
84
+ http.start
26
85
  end
27
86
 
28
87
  # Load tools (and resources/prompts) from the configured directories.
@@ -440,30 +499,95 @@ module ZeroMcp
440
499
  end
441
500
 
442
501
  def _resolve_credentials(tool_name)
443
- return nil if @config.credentials.empty?
444
- # Match credential namespace from tool name prefix
445
- @config.credentials.each do |ns, source|
446
- if tool_name.start_with?("#{ns}_") || tool_name.start_with?("#{ns}#{@config.separator}")
447
- return _resolve_credential_source(source)
448
- end
449
- end
450
- nil
502
+ Credentials.resolve(tool_name, @config, cache: @credential_cache)
451
503
  end
452
504
 
453
- def _resolve_credential_source(source)
454
- source = source.transform_keys(&:to_s) if source.is_a?(Hash)
455
- if source['env']
456
- val = ENV[source['env']]
457
- return nil if val.nil? || val.empty?
458
- begin; return JSON.parse(val); rescue; return val; end
505
+ # --- HTTP route helpers ---
506
+
507
+ def register_tool_route(http, tool, route_method, route_path)
508
+ # Convert :param segments to a regex for matching
509
+ param_names = []
510
+ regex_str = route_path.gsub(/:([A-Za-z_][A-Za-z0-9_]*)/) do
511
+ param_names << $1
512
+ '([^/]+)'
459
513
  end
460
- if source['file']
461
- path = File.expand_path(source['file'])
462
- return nil unless File.exist?(path)
463
- val = File.read(path).strip
464
- begin; return JSON.parse(val); rescue; return val; end
514
+ route_regex = /\A#{regex_str}\z/
515
+
516
+ http.mount_proc(route_path_prefix(route_path)) do |req, res|
517
+ next unless req.request_method == route_method
518
+
519
+ path_params = extract_path_params(req.path, route_regex, param_names)
520
+ unless path_params
521
+ res.status = 404
522
+ res.content_type = 'application/json'
523
+ res.body = JSON.generate({ 'ok' => false, 'error' => 'Not found' })
524
+ next
525
+ end
526
+
527
+ args = if route_method == 'GET'
528
+ query_args = WEBrick::HTTPUtils.parse_query(req.query_string || '')
529
+ query_args.merge(path_params)
530
+ else
531
+ body_args = begin
532
+ req.body && !req.body.empty? ? JSON.parse(req.body) : {}
533
+ rescue JSON::ParserError
534
+ {}
535
+ end
536
+ body_args.merge(path_params)
537
+ end
538
+
539
+ begin
540
+ ctx = Context.new(tool_name: tool.name, permissions: tool.permissions,
541
+ bypass: @config.bypass_permissions,
542
+ credentials: _resolve_credentials(tool.name))
543
+ timeout_secs = (tool.permissions.is_a?(Hash) && tool.permissions[:execute_timeout]) ||
544
+ (tool.permissions.is_a?(Hash) && tool.permissions['execute_timeout']) ||
545
+ @config.execute_timeout
546
+ result = Timeout.timeout(timeout_secs) { tool.call(args, ctx) }
547
+ res.content_type = 'application/json'
548
+ res.body = JSON.generate({ 'ok' => true, 'result' => result })
549
+ rescue => e
550
+ res.status = 500
551
+ res.content_type = 'application/json'
552
+ res.body = JSON.generate({ 'ok' => false, 'error' => e.message })
553
+ end
465
554
  end
466
- nil
467
555
  end
556
+
557
+ def route_path_prefix(route_path)
558
+ # WEBrick mounts on a fixed prefix; use the static prefix before first :param
559
+ route_path.split('/:').first || '/'
560
+ end
561
+
562
+ def extract_path_params(path, route_regex, param_names)
563
+ m = path.match(route_regex)
564
+ return nil unless m
565
+ result = {}
566
+ param_names.each_with_index { |name, i| result[name] = m[i + 1] }
567
+ result
568
+ end
569
+
570
+ # --- OpenAPI spec builder ---
571
+
572
+ def build_openapi_spec
573
+ OpenApi.build(@tools, @config)
574
+ end
575
+
576
+ SWAGGER_UI_HTML = <<~HTML.freeze
577
+ <!DOCTYPE html>
578
+ <html>
579
+ <head>
580
+ <title>ZeroMCP API</title>
581
+ <meta charset="utf-8"/>
582
+ <meta name="viewport" content="width=device-width, initial-scale=1">
583
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css">
584
+ </head>
585
+ <body>
586
+ <div id="swagger-ui"></div>
587
+ <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
588
+ <script>SwaggerUIBundle({ url: '/openapi.json', dom_id: '#swagger-ui' })</script>
589
+ </body>
590
+ </html>
591
+ HTML
468
592
  end
469
593
  end
data/lib/zeromcp/tool.rb CHANGED
@@ -4,13 +4,14 @@ require_relative 'schema'
4
4
 
5
5
  module ZeroMcp
6
6
  class Tool
7
- attr_reader :name, :description, :input, :permissions, :execute_block, :cached_schema
7
+ attr_reader :name, :description, :input, :permissions, :execute_block, :cached_schema, :route
8
8
 
9
- def initialize(name:, description: '', input: {}, permissions: {}, &block)
9
+ def initialize(name:, description: '', input: {}, permissions: {}, route: nil, &block)
10
10
  @name = name
11
11
  @description = description
12
12
  @input = input
13
13
  @permissions = permissions
14
+ @route = route
14
15
  @execute_block = block
15
16
  @cached_schema = Schema.to_json_schema(@input)
16
17
  end
@@ -18,6 +19,16 @@ module ZeroMcp
18
19
  def call(args, ctx = {})
19
20
  @execute_block.call(args, ctx)
20
21
  end
22
+
23
+ def route_method
24
+ return nil unless @route.is_a?(Hash)
25
+ (@route[:method] || @route['method'] || 'POST').upcase
26
+ end
27
+
28
+ def route_path
29
+ return nil unless @route.is_a?(Hash)
30
+ @route[:path] || @route['path'] || '/'
31
+ end
21
32
  end
22
33
 
23
34
  class Context
data/lib/zeromcp.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'zeromcp/server'
4
+ require_relative 'zeromcp/registry'
4
5
 
5
6
  module ZeroMcp
6
7
  def self.serve(config_path = nil)
@@ -8,4 +9,10 @@ module ZeroMcp
8
9
  server = Server.new(config)
9
10
  server.serve
10
11
  end
12
+
13
+ def self.serve_http(port: 3000, config_path: nil)
14
+ config = config_path ? Config.load(config_path) : Config.load
15
+ server = Server.new(config)
16
+ server.serve_http(port: port)
17
+ end
11
18
  end
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.2.0'
5
+ s.version = '0.3.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.2.0
4
+ version: 0.3.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-08 00:00:00.000000000 Z
11
+ date: 2026-08-06 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
@@ -22,6 +22,9 @@ files:
22
22
  - bin/zeromcp
23
23
  - lib/zeromcp.rb
24
24
  - lib/zeromcp/config.rb
25
+ - lib/zeromcp/credentials.rb
26
+ - lib/zeromcp/openapi.rb
27
+ - lib/zeromcp/registry.rb
25
28
  - lib/zeromcp/sandbox.rb
26
29
  - lib/zeromcp/scanner.rb
27
30
  - lib/zeromcp/schema.rb