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 +7 -0
- data/Gemfile +9 -0
- data/README.md +103 -0
- data/bin/zeromcp +37 -0
- data/lib/zeromcp/config.rb +33 -0
- data/lib/zeromcp/sandbox.rb +82 -0
- data/lib/zeromcp/scanner.rb +121 -0
- data/lib/zeromcp/schema.rb +72 -0
- data/lib/zeromcp/server.rb +202 -0
- data/lib/zeromcp/tool.rb +43 -0
- data/lib/zeromcp.rb +11 -0
- data/zeromcp.gemspec +18 -0
- metadata +54 -0
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
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
|
data/lib/zeromcp/tool.rb
ADDED
|
@@ -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
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: []
|