zeromcp 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 41b858d5fbd0613813087cb8ddb4af7f37ca0e14650dbd754937c8dd7450f5c6
4
+ data.tar.gz: 9a33ac859956b5dd640654a81e83c22be566216c415812e0d5f70444113ebe3f
5
+ SHA512:
6
+ metadata.gz: e0643b96c9e9c5e44bc67bf19bff9a564d947109a95ad1d1abf3d0d1d3a7fabc12e6333fca931096a84400b917a56c019aada8d7df18141adc03e2f44504e9c4
7
+ data.tar.gz: c980ba8e71e11bddfbfaa006ceda4a359c9fa5c05008781fa2876998bfe8f13bf0d7c34b4e23673fac6a5a2fc953043e45cebaa7ce2e2f5bbfc3313846454161
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :test do
8
+ gem 'minitest', '~> 5.0'
9
+ end
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # ZeroMCP — Ruby
2
+
3
+ Drop a `.rb` file in a folder, get a sandboxed MCP server. Stdio out of the box, zero dependencies.
4
+
5
+ ## Getting started
6
+
7
+ ```ruby
8
+ # tools/hello.rb — this is a complete MCP server
9
+ tool description: "Say hello to someone",
10
+ input: { name: "string" }
11
+
12
+ execute do |args, ctx|
13
+ "Hello, #{args['name']}!"
14
+ end
15
+ ```
16
+
17
+ ```sh
18
+ ruby -I lib bin/zeromcp serve ./tools
19
+ ```
20
+
21
+ That's it. Stdio works immediately. Drop another `.rb` file to add another tool. Delete a file to remove one.
22
+
23
+ ## vs. the official SDK
24
+
25
+ The official Ruby SDK requires server setup, transport configuration, and explicit tool registration. ZeroMCP is file-based — each tool is its own file, discovered automatically. Zero external dependencies.
26
+
27
+ In benchmarks, ZeroMCP Ruby handles 15,327 requests/second over stdio versus the official SDK's 12,935 — 1.2x faster with 50% less memory (12 MB vs 24 MB). Over HTTP (Rack+Puma), ZeroMCP serves 3,217 rps at 26 MB versus the official SDK's 2,163 rps at 49–56 MB. The official SDK crashed on binary garbage input and corrupted responses under slow tools in chaos testing. ZeroMCP survived 22/22 attacks.
28
+
29
+ The official SDK has **no sandbox**. ZeroMCP lets tools declare network, filesystem, and exec permissions.
30
+
31
+ Ruby passes all 10 conformance suites.
32
+
33
+ ## HTTP / Streamable HTTP
34
+
35
+ ZeroMCP doesn't own the HTTP layer. You bring your own framework; ZeroMCP gives you a `handle_request` method that takes a Hash and returns a Hash (or `nil` for notifications).
36
+
37
+ ```ruby
38
+ # response = server.handle_request(request)
39
+ ```
40
+
41
+ **Sinatra**
42
+
43
+ ```ruby
44
+ require 'sinatra'
45
+ require 'json'
46
+
47
+ post '/mcp' do
48
+ request_body = JSON.parse(request.body.read)
49
+ response = server.handle_request(request_body)
50
+
51
+ if response.nil?
52
+ status 204
53
+ else
54
+ content_type :json
55
+ response.to_json
56
+ end
57
+ end
58
+ ```
59
+
60
+ ## Requirements
61
+
62
+ - Ruby 3.0+
63
+ - No external dependencies
64
+
65
+ ## Install
66
+
67
+ ```sh
68
+ gem build zeromcp.gemspec
69
+ gem install zeromcp-0.1.0.gem
70
+ ```
71
+
72
+ ## Sandbox
73
+
74
+ ```ruby
75
+ tool description: "Fetch from our API",
76
+ input: { url: "string" },
77
+ permissions: {
78
+ network: ["api.example.com", "*.internal.dev"],
79
+ fs: false,
80
+ exec: false
81
+ }
82
+
83
+ execute do |args, ctx|
84
+ # ...
85
+ end
86
+ ```
87
+
88
+ ## Directory structure
89
+
90
+ Tools are discovered recursively. Subdirectory names become namespace prefixes:
91
+
92
+ ```
93
+ tools/
94
+ hello.rb -> tool "hello"
95
+ math/
96
+ add.rb -> tool "math_add"
97
+ ```
98
+
99
+ ## Testing
100
+
101
+ ```sh
102
+ ruby -I lib -I test -e 'Dir["test/**/*_test.rb"].each { |f| require_relative f }'
103
+ ```
data/bin/zeromcp ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/zeromcp'
5
+ require 'json'
6
+
7
+ # Parse --config flag from anywhere in ARGV
8
+ config_path = nil
9
+ args = ARGV.dup
10
+ if (idx = args.index('--config'))
11
+ config_path = args[idx + 1]
12
+ args.slice!(idx, 2)
13
+ end
14
+
15
+ command = args[0]
16
+ tools_dir = args[1]
17
+
18
+ case command
19
+ when 'serve'
20
+ if config_path
21
+ data = JSON.parse(File.read(config_path))
22
+ config = ZeroMcp::Config.new(data)
23
+ else
24
+ config = ZeroMcp::Config.load
25
+ config = ZeroMcp::Config.new('tools' => tools_dir) if tools_dir
26
+ end
27
+ server = ZeroMcp::Server.new(config)
28
+ server.serve
29
+ when 'audit'
30
+ # TODO
31
+ $stderr.puts '[zeromcp] audit not yet implemented for Ruby'
32
+ else
33
+ $stderr.puts 'Usage:'
34
+ $stderr.puts ' zeromcp serve [tools-directory] [--config <path>]'
35
+ $stderr.puts ' zeromcp audit [tools-directory]'
36
+ exit 1
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ZeroMcp
6
+ class Config
7
+ attr_reader :tools_dir, :separator, :logging, :bypass_permissions, :execute_timeout
8
+
9
+ def initialize(opts = {})
10
+ tools = opts[:tools_dir] || opts['tools'] || './tools'
11
+ @tools_dir = tools.is_a?(Array) ? tools : [tools]
12
+ @separator = opts[:separator] || opts['separator'] || '_'
13
+ @logging = opts[:logging] || opts['logging'] || false
14
+ @bypass_permissions = opts[:bypass_permissions] || opts['bypass_permissions'] || false
15
+ @execute_timeout = opts[:execute_timeout] || opts['execute_timeout'] || 30 # seconds
16
+ @credentials = opts[:credentials] || opts['credentials'] || {}
17
+ @namespacing = opts[:namespacing] || opts['namespacing'] || {}
18
+ end
19
+
20
+ attr_reader :credentials, :namespacing
21
+
22
+ def self.load(path = nil)
23
+ path ||= File.join(Dir.pwd, 'zeromcp.config.json')
24
+ return new unless File.exist?(path)
25
+
26
+ raw = File.read(path)
27
+ data = JSON.parse(raw)
28
+ new(data)
29
+ rescue JSON::ParserError
30
+ new
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroMcp
4
+ module Sandbox
5
+ module_function
6
+
7
+ def check_network_access(tool_name, hostname, permissions, bypass: false, logging: false)
8
+ network = permissions.key?(:network) ? permissions[:network] : permissions['network']
9
+
10
+ # No permissions or network not specified = full access
11
+ if network.nil?
12
+ log("#{tool_name} -> #{hostname}") if logging
13
+ return true
14
+ end
15
+
16
+ # network: true = full access
17
+ if network == true
18
+ log("#{tool_name} -> #{hostname}") if logging
19
+ return true
20
+ end
21
+
22
+ # network: false = denied
23
+ if network == false
24
+ if bypass
25
+ log("! #{tool_name} -> #{hostname} (network disabled -- bypassed)") if logging
26
+ return true
27
+ end
28
+ log("#{tool_name} x #{hostname} (network disabled)") if logging
29
+ return false
30
+ end
31
+
32
+ # network: [] (empty array) = denied
33
+ if network.is_a?(Array) && network.empty?
34
+ if bypass
35
+ log("! #{tool_name} -> #{hostname} (network disabled -- bypassed)") if logging
36
+ return true
37
+ end
38
+ log("#{tool_name} x #{hostname} (network disabled)") if logging
39
+ return false
40
+ end
41
+
42
+ # network: ["host1", "*.host2"] = allowlist
43
+ if network.is_a?(Array)
44
+ if allowed?(hostname, network)
45
+ log("#{tool_name} -> #{hostname}") if logging
46
+ return true
47
+ end
48
+ if bypass
49
+ log("! #{tool_name} -> #{hostname} (not in allowlist -- bypassed)") if logging
50
+ return true
51
+ end
52
+ log("#{tool_name} x #{hostname} (not in allowlist)") if logging
53
+ return false
54
+ end
55
+
56
+ # Unknown type — allow by default
57
+ true
58
+ end
59
+
60
+ def allowed?(hostname, allowlist)
61
+ allowlist.any? do |pattern|
62
+ if pattern.start_with?('*.')
63
+ suffix = pattern[1..] # e.g. ".example.com"
64
+ base = pattern[2..] # e.g. "example.com"
65
+ hostname.end_with?(suffix) || hostname == base
66
+ else
67
+ hostname == pattern
68
+ end
69
+ end
70
+ end
71
+
72
+ def extract_hostname(url)
73
+ after_scheme = url.sub(%r{^[a-z]+://}, '')
74
+ host_port = after_scheme.split('/').first || after_scheme
75
+ host_port.split(':').first || host_port
76
+ end
77
+
78
+ def log(msg)
79
+ $stderr.puts "[zeromcp] #{msg}"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module ZeroMcp
6
+ class Scanner
7
+ attr_reader :tools
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ @tools = {}
12
+ end
13
+
14
+ def scan
15
+ @tools.clear
16
+ dirs = @config.tools_dir
17
+ dirs = [dirs] unless dirs.is_a?(Array)
18
+
19
+ dirs.each do |d|
20
+ dir = File.expand_path(d)
21
+ unless Dir.exist?(dir)
22
+ $stderr.puts "[zeromcp] Cannot read tools directory: #{dir}"
23
+ next
24
+ end
25
+ scan_dir(dir, dir)
26
+ end
27
+ @tools
28
+ end
29
+
30
+ private
31
+
32
+ def scan_dir(dir, root_dir)
33
+ Dir.entries(dir).sort.each do |entry|
34
+ next if entry.start_with?('.')
35
+
36
+ full_path = File.join(dir, entry)
37
+
38
+ if File.directory?(full_path)
39
+ scan_dir(full_path, root_dir)
40
+ elsif entry.end_with?('.rb')
41
+ load_tool(full_path, root_dir)
42
+ end
43
+ end
44
+ end
45
+
46
+ def load_tool(file_path, root_dir)
47
+ name = build_name(file_path, root_dir)
48
+
49
+ # Each tool file should return a hash via a special structure.
50
+ # We use a sandboxed binding to evaluate the file.
51
+ tool_def = load_tool_file(file_path)
52
+ return unless tool_def
53
+
54
+ log_permissions(name, tool_def[:permissions])
55
+
56
+ @tools[name] = Tool.new(
57
+ name: name,
58
+ description: tool_def[:description] || '',
59
+ input: tool_def[:input] || {},
60
+ permissions: tool_def[:permissions] || {}
61
+ ) { |args, ctx| tool_def[:execute].call(args, ctx) }
62
+
63
+ $stderr.puts "[zeromcp] Loaded: #{name}"
64
+ rescue => e
65
+ rel = Pathname.new(file_path).relative_path_from(Pathname.new(root_dir))
66
+ $stderr.puts "[zeromcp] Error loading #{rel}: #{e.message}"
67
+ end
68
+
69
+ def load_tool_file(file_path)
70
+ loader = ToolLoader.new
71
+ loader.instance_eval(File.read(file_path), file_path)
72
+ loader._tool_definition
73
+ end
74
+
75
+ def build_name(file_path, root_dir)
76
+ rel = Pathname.new(file_path).relative_path_from(Pathname.new(root_dir)).to_s
77
+ parts = rel.split('/')
78
+ filename = File.basename(parts.pop, '.rb')
79
+
80
+ if parts.length > 0
81
+ dir_prefix = parts[0]
82
+ "#{dir_prefix}#{@config.separator}#{filename}"
83
+ else
84
+ filename
85
+ end
86
+ end
87
+
88
+ def log_permissions(name, permissions)
89
+ return unless permissions
90
+
91
+ elevated = []
92
+ elevated << "fs: #{permissions[:fs]}" if permissions[:fs]
93
+ elevated << 'exec' if permissions[:exec]
94
+ if elevated.any?
95
+ $stderr.puts "[zeromcp] #{name} requests elevated permissions: #{elevated.join(' | ')}"
96
+ end
97
+ end
98
+ end
99
+
100
+ # ToolLoader provides the DSL for tool files
101
+ class ToolLoader
102
+ def initialize
103
+ @definition = {}
104
+ end
105
+
106
+ def tool(description: '', permissions: {}, input: {})
107
+ @definition[:description] = description
108
+ @definition[:permissions] = permissions
109
+ @definition[:input] = input
110
+ end
111
+
112
+ def execute(&block)
113
+ @definition[:execute] = block
114
+ end
115
+
116
+ def _tool_definition
117
+ return nil unless @definition[:execute]
118
+ @definition
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroMcp
4
+ module Schema
5
+ TYPE_MAP = {
6
+ 'string' => { 'type' => 'string' },
7
+ 'number' => { 'type' => 'number' },
8
+ 'boolean' => { 'type' => 'boolean' },
9
+ 'object' => { 'type' => 'object' },
10
+ 'array' => { 'type' => 'array' }
11
+ }.freeze
12
+
13
+ def self.to_json_schema(input)
14
+ return { 'type' => 'object', 'properties' => {}, 'required' => [] } if input.nil? || input.empty?
15
+
16
+ properties = {}
17
+ required = []
18
+
19
+ input.each do |key, value|
20
+ key = key.to_s
21
+ if value.is_a?(String)
22
+ mapped = TYPE_MAP[value]
23
+ raise "Unknown type \"#{value}\" for field \"#{key}\"" unless mapped
24
+
25
+ properties[key] = mapped.dup
26
+ required << key
27
+ elsif value.is_a?(Hash)
28
+ type = value[:type] || value['type']
29
+ mapped = TYPE_MAP[type.to_s]
30
+ raise "Unknown type \"#{type}\" for field \"#{key}\"" unless mapped
31
+
32
+ prop = mapped.dup
33
+ desc = value[:description] || value['description']
34
+ prop['description'] = desc if desc
35
+ properties[key] = prop
36
+
37
+ optional = value[:optional] || value['optional']
38
+ required << key unless optional
39
+ end
40
+ end
41
+
42
+ { 'type' => 'object', 'properties' => properties, 'required' => required }
43
+ end
44
+
45
+ def self.validate(input, schema)
46
+ errors = []
47
+
48
+ (schema['required'] || []).each do |key|
49
+ if input[key].nil?
50
+ errors << "Missing required field: #{key}"
51
+ end
52
+ end
53
+
54
+ input.each do |key, value|
55
+ prop = schema['properties'][key]
56
+ next unless prop
57
+
58
+ actual = value.is_a?(Array) ? 'array' : value.class.name.downcase
59
+ actual = 'number' if value.is_a?(Numeric)
60
+ actual = 'boolean' if value == true || value == false
61
+ actual = 'string' if value.is_a?(String)
62
+ actual = 'object' if value.is_a?(Hash)
63
+
64
+ if actual != prop['type']
65
+ errors << "Field \"#{key}\" expected #{prop['type']}, got #{actual}"
66
+ end
67
+ end
68
+
69
+ errors
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'timeout'
5
+ require_relative 'schema'
6
+ require_relative 'config'
7
+ require_relative 'tool'
8
+ require_relative 'scanner'
9
+ require_relative 'sandbox'
10
+
11
+ module ZeroMcp
12
+ class Server
13
+ def initialize(config = nil)
14
+ @config = config || Config.load
15
+ @scanner = Scanner.new(@config)
16
+ @tools = {}
17
+ end
18
+
19
+ # Load tools from the configured directories. Call this before using
20
+ # handle_request directly (serve calls this automatically).
21
+ def load_tools
22
+ @tools = @scanner.scan
23
+ $stderr.puts "[zeromcp] #{@tools.size} tool(s) loaded"
24
+ end
25
+
26
+ def serve
27
+ $stdout.sync = true
28
+ $stderr.sync = true
29
+ $stdin.set_encoding('UTF-8')
30
+ $stdout.set_encoding('UTF-8')
31
+ @tools = @scanner.scan
32
+ $stderr.puts "[zeromcp] #{@tools.size} tool(s) loaded"
33
+ $stderr.puts "[zeromcp] stdio transport ready"
34
+
35
+ $stdin.each_line do |line|
36
+ begin
37
+ line = line.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').strip
38
+ rescue StandardError
39
+ next
40
+ end
41
+ next if line.empty?
42
+
43
+ begin
44
+ request = JSON.parse(line)
45
+ rescue JSON::ParserError, EncodingError, StandardError
46
+ next
47
+ end
48
+
49
+ next unless request.is_a?(Hash)
50
+
51
+ response = handle_request(request)
52
+ if response
53
+ $stdout.puts JSON.generate(response)
54
+ $stdout.flush
55
+ end
56
+ end
57
+ end
58
+
59
+ # Process a single JSON-RPC request hash and return a response hash.
60
+ # Returns nil for notifications that require no response.
61
+ #
62
+ # Note: tools must be loaded first via #serve or by calling scanner.scan
63
+ # manually if using this method directly for HTTP integration.
64
+ #
65
+ # Usage:
66
+ # response = server.handle_request({"jsonrpc" => "2.0", "id" => 1, "method" => "tools/list"})
67
+ def handle_request(request)
68
+ id = request['id']
69
+ method = request['method']
70
+ params = request['params'] || {}
71
+
72
+ # Notifications (no id) for known notification methods
73
+ if id.nil? && method == 'notifications/initialized'
74
+ return nil
75
+ end
76
+
77
+ case method
78
+ 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
+ }
93
+
94
+ when 'tools/list'
95
+ {
96
+ 'jsonrpc' => '2.0',
97
+ 'id' => id,
98
+ 'result' => {
99
+ 'tools' => build_tool_list
100
+ }
101
+ }
102
+
103
+ when 'tools/call'
104
+ {
105
+ 'jsonrpc' => '2.0',
106
+ 'id' => id,
107
+ 'result' => call_tool(params)
108
+ }
109
+
110
+ when 'ping'
111
+ { 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }
112
+
113
+ else
114
+ return nil if id.nil?
115
+ {
116
+ 'jsonrpc' => '2.0',
117
+ 'id' => id,
118
+ 'error' => { 'code' => -32601, 'message' => "Method not found: #{method}" }
119
+ }
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def build_tool_list
126
+ @tools.map do |name, tool|
127
+ {
128
+ 'name' => name,
129
+ 'description' => tool.description,
130
+ 'inputSchema' => Schema.to_json_schema(tool.input)
131
+ }
132
+ end
133
+ end
134
+
135
+ def call_tool(params)
136
+ name = params.is_a?(Hash) ? params['name'] : nil
137
+ args = params.is_a?(Hash) ? (params['arguments'] || {}) : {}
138
+ args = {} if args.nil?
139
+
140
+ tool = @tools[name]
141
+ unless tool
142
+ return {
143
+ 'content' => [{ 'type' => 'text', 'text' => "Unknown tool: #{name}" }],
144
+ 'isError' => true
145
+ }
146
+ end
147
+
148
+ schema = Schema.to_json_schema(tool.input)
149
+ errors = Schema.validate(args, schema)
150
+ if errors.any?
151
+ return {
152
+ 'content' => [{ 'type' => 'text', 'text' => "Validation errors:\n#{errors.join("\n")}" }],
153
+ 'isError' => true
154
+ }
155
+ end
156
+
157
+ begin
158
+ ctx = Context.new(tool_name: name, permissions: tool.permissions, bypass: @config.bypass_permissions, credentials: _resolve_credentials(name))
159
+
160
+ # Tool-level timeout overrides config default
161
+ timeout_secs = (tool.permissions.is_a?(Hash) && tool.permissions[:execute_timeout]) ||
162
+ (tool.permissions.is_a?(Hash) && tool.permissions['execute_timeout']) ||
163
+ @config.execute_timeout
164
+
165
+ result = Timeout.timeout(timeout_secs) { tool.call(args, ctx) }
166
+ text = result.is_a?(String) ? result : JSON.generate(result)
167
+ { 'content' => [{ 'type' => 'text', 'text' => text }] }
168
+ rescue Timeout::Error
169
+ { 'content' => [{ 'type' => 'text', 'text' => "Tool \"#{name}\" timed out after #{timeout_secs}s" }], 'isError' => true }
170
+ rescue => e
171
+ { 'content' => [{ 'type' => 'text', 'text' => "Error: #{e.message}" }], 'isError' => true }
172
+ end
173
+ end
174
+
175
+ def _resolve_credentials(tool_name)
176
+ return nil if @config.credentials.empty?
177
+ # Match credential namespace from tool name prefix
178
+ @config.credentials.each do |ns, source|
179
+ if tool_name.start_with?("#{ns}_") || tool_name.start_with?("#{ns}#{@config.separator}")
180
+ return _resolve_credential_source(source)
181
+ end
182
+ end
183
+ nil
184
+ end
185
+
186
+ def _resolve_credential_source(source)
187
+ source = source.transform_keys(&:to_s) if source.is_a?(Hash)
188
+ if source['env']
189
+ val = ENV[source['env']]
190
+ return nil if val.nil? || val.empty?
191
+ begin; return JSON.parse(val); rescue; return val; end
192
+ end
193
+ if source['file']
194
+ path = File.expand_path(source['file'])
195
+ return nil unless File.exist?(path)
196
+ val = File.read(path).strip
197
+ begin; return JSON.parse(val); rescue; return val; end
198
+ end
199
+ nil
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroMcp
4
+ class Tool
5
+ attr_reader :name, :description, :input, :permissions, :execute_block
6
+
7
+ def initialize(name:, description: '', input: {}, permissions: {}, &block)
8
+ @name = name
9
+ @description = description
10
+ @input = input
11
+ @permissions = permissions
12
+ @execute_block = block
13
+ end
14
+
15
+ def call(args, ctx = {})
16
+ @execute_block.call(args, ctx)
17
+ end
18
+ end
19
+
20
+ class Context
21
+ attr_reader :credentials, :tool_name, :permissions, :bypass
22
+
23
+ def initialize(tool_name:, credentials: nil, permissions: {}, bypass: false)
24
+ @tool_name = tool_name
25
+ @credentials = credentials
26
+ @permissions = permissions
27
+ @bypass = bypass
28
+ end
29
+ end
30
+
31
+ # DSL module for tool files
32
+ module ToolDSL
33
+ def self.included(base)
34
+ base.extend(ClassMethods)
35
+ end
36
+
37
+ module ClassMethods
38
+ def tool_metadata
39
+ @tool_metadata ||= {}
40
+ end
41
+ end
42
+ end
43
+ end
data/lib/zeromcp.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'zeromcp/server'
4
+
5
+ module ZeroMcp
6
+ def self.serve(config_path = nil)
7
+ config = config_path ? Config.load(config_path) : Config.load
8
+ server = Server.new(config)
9
+ server.serve
10
+ end
11
+ end
data/zeromcp.gemspec ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'zeromcp'
5
+ s.version = '0.1.0'
6
+ s.summary = 'Zero-config MCP runtime'
7
+ s.description = 'Drop tool files in a directory, get a working MCP server. Zero boilerplate.'
8
+ s.authors = ['Antidrift']
9
+ s.email = 'hello@probeo.io'
10
+ s.homepage = 'https://github.com/antidrift-dev/zeromcp'
11
+ s.license = 'MIT'
12
+
13
+ s.required_ruby_version = '>= 3.0.0'
14
+
15
+ s.files = Dir['lib/**/*.rb'] + ['zeromcp.gemspec', 'Gemfile', 'README.md']
16
+ s.executables = ['zeromcp']
17
+ s.require_paths = ['lib']
18
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zeromcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Antidrift
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-06 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Drop tool files in a directory, get a working MCP server. Zero boilerplate.
14
+ email: hello@probeo.io
15
+ executables:
16
+ - zeromcp
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - Gemfile
21
+ - README.md
22
+ - bin/zeromcp
23
+ - lib/zeromcp.rb
24
+ - lib/zeromcp/config.rb
25
+ - lib/zeromcp/sandbox.rb
26
+ - lib/zeromcp/scanner.rb
27
+ - lib/zeromcp/schema.rb
28
+ - lib/zeromcp/server.rb
29
+ - lib/zeromcp/tool.rb
30
+ - zeromcp.gemspec
31
+ homepage: https://github.com/antidrift-dev/zeromcp
32
+ licenses:
33
+ - MIT
34
+ metadata: {}
35
+ post_install_message:
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 3.0.0
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.5.22
51
+ signing_key:
52
+ specification_version: 4
53
+ summary: Zero-config MCP runtime
54
+ test_files: []