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 +4 -4
- data/README.md +7 -2
- data/lib/zeromcp/config.rb +4 -2
- data/lib/zeromcp/credentials.rb +43 -0
- data/lib/zeromcp/openapi.rb +119 -0
- data/lib/zeromcp/registry.rb +62 -0
- data/lib/zeromcp/scanner.rb +4 -2
- data/lib/zeromcp/server.rb +147 -23
- data/lib/zeromcp/tool.rb +13 -2
- data/lib/zeromcp.rb +7 -0
- data/zeromcp.gemspec +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa95cc5fe08e2a54b9034a2248b978e1a01919bd3cb0249f444031e070f0dcd1
|
|
4
|
+
data.tar.gz: ae4cd9f4405f8b9786f0bf5e238898b8260d5d37b5a8aac9bdd96ac3ae02876a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
107
|
+
ruby -I lib -I test -e 'Dir["test/**/test_*.rb"].each { |f| require_relative f }'
|
|
103
108
|
```
|
data/lib/zeromcp/config.rb
CHANGED
|
@@ -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
|
data/lib/zeromcp/scanner.rb
CHANGED
|
@@ -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)
|
data/lib/zeromcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
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.
|
|
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-
|
|
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
|