zeromcp 0.2.2 → 0.4.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/Gemfile +1 -0
- data/README.md +88 -12
- data/lib/zeromcp/config.rb +65 -5
- data/lib/zeromcp/credentials.rb +50 -0
- data/lib/zeromcp/openapi.rb +119 -0
- data/lib/zeromcp/registry.rb +77 -0
- data/lib/zeromcp/remote.rb +117 -0
- data/lib/zeromcp/scanner.rb +46 -17
- data/lib/zeromcp/server.rb +149 -53
- data/lib/zeromcp/tool.rb +22 -4
- data/lib/zeromcp.rb +2 -0
- data/zeromcp.gemspec +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b5b1e9efe4dd7daa0f3ac769bc0989684a322df09a9b6f0886b1f8c9b11be14
|
|
4
|
+
data.tar.gz: b8b9a22497327fdf3618c01d959e01d692a83f6c4785d6e7b655bdd5eacdc8fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e49425f27d7726ca510f08014d5e5bb9418a5da74ce91f8a7ea746fed9e7c6d7ea9f1a05fe72197bf70c043952143e972707699c41fe3a7308fc490ad669230d
|
|
7
|
+
data.tar.gz: 91328849237941d7ce8aa4a4dd86fb63a8d20c375e40000df390c302ef24e36cd166ca311cc04987fdc7416af51e7a73371ff9b0dfffd08edbcb9919ed20f539
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -32,31 +32,105 @@ Ruby passes all 10 conformance suites.
|
|
|
32
32
|
|
|
33
33
|
## HTTP / Streamable HTTP
|
|
34
34
|
|
|
35
|
-
ZeroMCP doesn't own the HTTP layer
|
|
35
|
+
ZeroMCP doesn't own the HTTP layer and ships no HTTP server. You mount it in the framework you already run. `ZeroMcp.create_registry` gives you everything as plain data:
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
- `registry.routes`: one entry per tool with a `route:` (`name`, `method`, `path`, `tool`)
|
|
38
|
+
- `registry.openapi`: an OpenAPI 3.0 document (a Hash) for the routed tools
|
|
39
|
+
- `registry.mcp`: a callable that takes a JSON-RPC Hash and returns a response Hash (or `nil` for notifications)
|
|
40
|
+
- `registry.context_for(tool)`: a ready-made `ctx` (credentials, permissions, runtime) for calling a routed tool yourself
|
|
41
|
+
|
|
42
|
+
A config with an `http` transport now fails at startup and points you here.
|
|
40
43
|
|
|
41
44
|
**Sinatra**
|
|
42
45
|
|
|
43
46
|
```ruby
|
|
44
47
|
require 'sinatra'
|
|
45
48
|
require 'json'
|
|
49
|
+
require 'zeromcp'
|
|
50
|
+
|
|
51
|
+
config = ZeroMcp::Config.load
|
|
52
|
+
tools = ZeroMcp::Scanner.new(config).scan
|
|
53
|
+
registry = ZeroMcp.create_registry(tools, config: config)
|
|
46
54
|
|
|
55
|
+
# MCP over HTTP
|
|
47
56
|
post '/mcp' do
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
response = registry.mcp.call(JSON.parse(request.body.read))
|
|
58
|
+
halt 204 if response.nil?
|
|
59
|
+
content_type :json
|
|
60
|
+
response.to_json
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# OpenAPI
|
|
64
|
+
get '/openapi.json' do
|
|
65
|
+
content_type :json
|
|
66
|
+
registry.openapi.to_json
|
|
67
|
+
end
|
|
50
68
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
# One HTTP route per routed tool. Sinatra uses the same :param syntax.
|
|
70
|
+
registry.routes.each do |route|
|
|
71
|
+
send(route.method.downcase, route.path) do
|
|
72
|
+
body = request.body.read
|
|
73
|
+
args = request.GET.merge(body.empty? ? {} : JSON.parse(body))
|
|
74
|
+
args = args.merge(params.slice(*route.path.scan(/:(\w+)/).flatten))
|
|
75
|
+
result = route.tool.call(args, registry.context_for(route.tool))
|
|
54
76
|
content_type :json
|
|
55
|
-
|
|
77
|
+
{ ok: true, result: result }.to_json
|
|
56
78
|
end
|
|
57
79
|
end
|
|
58
80
|
```
|
|
59
81
|
|
|
82
|
+
`Server#handle_request` is still there if you only need `/mcp`: `server = ZeroMcp::Server.new(config); server.load_tools; server.handle_request(hash)`.
|
|
83
|
+
|
|
84
|
+
## Runtime context
|
|
85
|
+
|
|
86
|
+
Pass `get_runtime:` to hand your tools something from your app (a DB handle, the current request's user). It is called on every local tool execution, and its return value is `ctx.runtime`:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
registry = ZeroMcp.create_registry(tools, get_runtime: -> { { db: DB } })
|
|
90
|
+
# or: ZeroMcp::Server.new(config, get_runtime: -> { { db: DB } })
|
|
91
|
+
|
|
92
|
+
# tools/report.rb
|
|
93
|
+
execute do |args, ctx|
|
|
94
|
+
ctx.runtime[:db].query(args['sql'])
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Without `get_runtime:`, `ctx.runtime` is `nil`. Remote tools never call it.
|
|
99
|
+
|
|
100
|
+
## Remote servers
|
|
101
|
+
|
|
102
|
+
ZeroMCP can federate other MCP servers over HTTP. Their tools appear as `<name>.<tool>`; a local tool with the same name wins.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"tools": "./tools",
|
|
107
|
+
"remote": [
|
|
108
|
+
{ "name": "probeo", "url": "https://mcp.example.com/mcp", "auth": "env:PROBEO_TOKEN", "tools": ["list_pages"] }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`auth` is sent as `Authorization: Bearer <token>`; `env:VAR` reads it from the environment. `tools` is an optional allowlist of upstream tool names. A server that fails to connect is logged and skipped. The registry takes the same list: `ZeroMcp.create_registry(tools, remote: [...])` (it connects before returning). Remote tools have no route, so they are served from `mcp` only.
|
|
114
|
+
|
|
115
|
+
## Hot reload
|
|
116
|
+
|
|
117
|
+
Set `"autoload_tools": true` and `zeromcp serve` polls the tool directories once a second. When a `.rb` file is added or changed it rescans, loads the new tools, and sends `notifications/tools/list_changed` over stdio. Removed files keep their tools until restart.
|
|
118
|
+
|
|
119
|
+
## Tool sources and namespacing
|
|
120
|
+
|
|
121
|
+
`tools` takes a path, or an array of paths and `{ "path": ..., "prefix": ... }` entries. A prefix is prepended to every tool from that source:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"tools": ["./tools", { "path": "./vendor/github-tools", "prefix": "gh" }],
|
|
126
|
+
"namespacing": { "math": { "prefix": "m" } }
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`namespacing` renames a subdirectory's prefix (`math/add.rb` becomes `m_add`; `""` drops it). Credentials stay keyed by the directory name. `resources` and `prompts` accept the same entry shapes.
|
|
131
|
+
|
|
132
|
+
`icon` accepts a data URI, a file path, or an `http(s)` URL.
|
|
133
|
+
|
|
60
134
|
## Requirements
|
|
61
135
|
|
|
62
136
|
- Ruby 3.0+
|
|
@@ -66,7 +140,7 @@ end
|
|
|
66
140
|
|
|
67
141
|
```sh
|
|
68
142
|
gem build zeromcp.gemspec
|
|
69
|
-
gem install zeromcp-0.
|
|
143
|
+
gem install zeromcp-0.2.2.gem
|
|
70
144
|
```
|
|
71
145
|
|
|
72
146
|
## Sandbox
|
|
@@ -98,6 +172,8 @@ tools/
|
|
|
98
172
|
|
|
99
173
|
## Testing
|
|
100
174
|
|
|
175
|
+
The tests use WEBrick for fake upstream servers (`gem 'webrick'`, in the Gemfile's test group). The gem itself doesn't need it.
|
|
176
|
+
|
|
101
177
|
```sh
|
|
102
|
-
ruby -I lib -I test -e 'Dir["test
|
|
178
|
+
ruby -I lib -I test -e 'Dir["test/**/test_*.rb"].each { |f| require_relative f }'
|
|
103
179
|
```
|
data/lib/zeromcp/config.rb
CHANGED
|
@@ -2,22 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'base64'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'uri'
|
|
5
7
|
|
|
6
8
|
module ZeroMcp
|
|
7
9
|
class Config
|
|
8
|
-
attr_reader :tools_dir, :resources_dir, :prompts_dir,
|
|
10
|
+
attr_reader :tools_dir, :tool_sources, :resources_dir, :prompts_dir,
|
|
9
11
|
:separator, :logging, :bypass_permissions, :execute_timeout,
|
|
10
|
-
:page_size, :icon
|
|
12
|
+
:page_size, :icon, :title, :remote, :autoload_tools, :transport
|
|
11
13
|
|
|
12
14
|
def initialize(opts = {})
|
|
13
15
|
tools = opts[:tools_dir] || opts['tools'] || './tools'
|
|
14
|
-
@
|
|
16
|
+
@tool_sources = Config.resolve_sources(tools)
|
|
17
|
+
@tools_dir = @tool_sources.map { |s| s['path'] }
|
|
15
18
|
|
|
16
19
|
resources = opts[:resources_dir] || opts['resources']
|
|
17
|
-
@resources_dir = resources ? (resources.
|
|
20
|
+
@resources_dir = resources ? Config.resolve_sources(resources).map { |s| s['path'] } : []
|
|
18
21
|
|
|
19
22
|
prompts = opts[:prompts_dir] || opts['prompts']
|
|
20
|
-
@prompts_dir = prompts ? (prompts.
|
|
23
|
+
@prompts_dir = prompts ? Config.resolve_sources(prompts).map { |s| s['path'] } : []
|
|
21
24
|
|
|
22
25
|
@separator = opts[:separator] || opts['separator'] || '_'
|
|
23
26
|
@logging = opts[:logging] || opts['logging'] || false
|
|
@@ -28,6 +31,10 @@ module ZeroMcp
|
|
|
28
31
|
@namespacing = opts[:namespacing] || opts['namespacing'] || {}
|
|
29
32
|
@page_size = opts[:page_size] || opts['page_size'] || 0
|
|
30
33
|
@icon = opts[:icon] || opts['icon']
|
|
34
|
+
@title = opts[:title] || opts['title'] || 'ZeroMCP'
|
|
35
|
+
@remote = opts[:remote] || opts['remote'] || []
|
|
36
|
+
@autoload_tools = opts[:autoload_tools] || opts['autoload_tools'] || false
|
|
37
|
+
@transport = opts[:transport] || opts['transport']
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
attr_reader :credentials, :cache_credentials, :namespacing
|
|
@@ -43,13 +50,53 @@ module ZeroMcp
|
|
|
43
50
|
new
|
|
44
51
|
end
|
|
45
52
|
|
|
53
|
+
# Normalize a `tools` / `resources` / `prompts` value (a string, or an
|
|
54
|
+
# array of strings and { 'path' => ..., 'prefix' => ... } hashes) to an
|
|
55
|
+
# array of { 'path' => String, 'prefix' => String|nil }.
|
|
56
|
+
def self.resolve_sources(value)
|
|
57
|
+
list = value.is_a?(Array) ? value : [value]
|
|
58
|
+
list.map do |entry|
|
|
59
|
+
if entry.is_a?(Hash)
|
|
60
|
+
{ 'path' => entry['path'] || entry[:path], 'prefix' => entry['prefix'] || entry[:prefix] }
|
|
61
|
+
else
|
|
62
|
+
{ 'path' => entry, 'prefix' => nil }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True when `transport` asks for the removed built-in HTTP server: the
|
|
68
|
+
# string "http", a { 'type' => 'http' } hash, or an array containing either.
|
|
69
|
+
def http_transport?
|
|
70
|
+
list = @transport.is_a?(Array) ? @transport : [@transport]
|
|
71
|
+
list.any? do |t|
|
|
72
|
+
type = t.is_a?(Hash) ? (t['type'] || t[:type]) : t
|
|
73
|
+
type.to_s == 'http'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Resolve an auth value: "env:VAR" reads ENV[VAR] (warning when unset),
|
|
78
|
+
# anything else is returned as-is. nil passes through.
|
|
79
|
+
def self.resolve_auth(auth)
|
|
80
|
+
return nil if auth.nil?
|
|
81
|
+
|
|
82
|
+
if auth.start_with?('env:')
|
|
83
|
+
var = auth[4..]
|
|
84
|
+
val = ENV[var]
|
|
85
|
+
$stderr.puts "[zeromcp] Warning: environment variable #{var} not set" if val.nil?
|
|
86
|
+
return val
|
|
87
|
+
end
|
|
88
|
+
auth
|
|
89
|
+
end
|
|
90
|
+
|
|
46
91
|
# Resolve an icon config value to a data URI. Supports:
|
|
47
92
|
# - data: URIs (returned as-is)
|
|
93
|
+
# - http(s) URLs (fetched and base64 encoded)
|
|
48
94
|
# - Local file paths (read and base64 encode)
|
|
49
95
|
# Returns nil on failure or if icon is not set.
|
|
50
96
|
def self.resolve_icon(icon)
|
|
51
97
|
return nil if icon.nil? || icon.empty?
|
|
52
98
|
return icon if icon.start_with?('data:')
|
|
99
|
+
return fetch_icon(icon) if icon.start_with?('http://', 'https://')
|
|
53
100
|
|
|
54
101
|
# File path
|
|
55
102
|
path = File.expand_path(icon)
|
|
@@ -64,6 +111,19 @@ module ZeroMcp
|
|
|
64
111
|
nil
|
|
65
112
|
end
|
|
66
113
|
|
|
114
|
+
def self.fetch_icon(url)
|
|
115
|
+
res = Net::HTTP.get_response(URI(url))
|
|
116
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
117
|
+
$stderr.puts "[zeromcp] Warning: failed to fetch icon #{url}: #{res.code}"
|
|
118
|
+
return nil
|
|
119
|
+
end
|
|
120
|
+
content_type = res['content-type'] || 'image/png'
|
|
121
|
+
"data:#{content_type};base64,#{Base64.strict_encode64(res.body || '')}"
|
|
122
|
+
rescue => e
|
|
123
|
+
$stderr.puts "[zeromcp] Warning: failed to fetch icon #{url}: #{e.message}"
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
67
127
|
ICON_MIME = {
|
|
68
128
|
'.png' => 'image/png',
|
|
69
129
|
'.jpg' => 'image/jpeg',
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ZeroMcp
|
|
6
|
+
module Credentials
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# ns: the credentials key to use (a scanned tool's top-level directory).
|
|
10
|
+
# When nil, the key is found by matching the tool name's prefix.
|
|
11
|
+
def resolve(tool_name, config, cache: nil, ns: nil)
|
|
12
|
+
return nil if config.credentials.empty?
|
|
13
|
+
if ns
|
|
14
|
+
key = config.credentials.key?(ns) ? ns : (config.credentials.key?(ns.to_sym) ? ns.to_sym : nil)
|
|
15
|
+
return nil unless key
|
|
16
|
+
return resolve_for_ns(ns.to_s, config.credentials[key], config, cache)
|
|
17
|
+
end
|
|
18
|
+
config.credentials.each do |key, source|
|
|
19
|
+
if tool_name.start_with?("#{key}_") || tool_name.start_with?("#{key}#{config.separator}")
|
|
20
|
+
return resolve_for_ns(key.to_s, source, config, cache)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve_for_ns(ns, source, config, cache)
|
|
27
|
+
return resolve_source(source) unless config.cache_credentials
|
|
28
|
+
return cache[ns] if cache && cache.key?(ns)
|
|
29
|
+
creds = resolve_source(source)
|
|
30
|
+
cache[ns] = creds if cache
|
|
31
|
+
creds
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_source(source)
|
|
35
|
+
source = source.transform_keys(&:to_s) if source.is_a?(Hash)
|
|
36
|
+
if source['env']
|
|
37
|
+
val = ENV[source['env']]
|
|
38
|
+
return nil if val.nil? || val.empty?
|
|
39
|
+
begin; return JSON.parse(val); rescue; return val; end
|
|
40
|
+
end
|
|
41
|
+
if source['file']
|
|
42
|
+
path = File.expand_path(source['file'])
|
|
43
|
+
return nil unless File.exist?(path)
|
|
44
|
+
val = File.read(path).strip
|
|
45
|
+
begin; return JSON.parse(val); rescue; return val; end
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'server'
|
|
4
|
+
require_relative 'openapi'
|
|
5
|
+
require_relative 'credentials'
|
|
6
|
+
require_relative 'remote'
|
|
7
|
+
|
|
8
|
+
module ZeroMcp
|
|
9
|
+
module Registry
|
|
10
|
+
RouteDefinition = Struct.new(:name, :method, :path, :tool, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
ToolRegistry = Struct.new(:routes, :openapi, :mcp, :config, :credential_cache, :get_runtime, keyword_init: true) do
|
|
13
|
+
# Build a ready-to-use Context for `tool`, using the same config-driven
|
|
14
|
+
# credential/permission rules Server#call_tool already applies — so a
|
|
15
|
+
# caller invoking a routed tool directly (route.tool.call(args, ctx))
|
|
16
|
+
# doesn't have to hand-roll credential lookup. ctx.runtime comes from
|
|
17
|
+
# the registry's get_runtime hook, when one was given.
|
|
18
|
+
def context_for(tool)
|
|
19
|
+
ZeroMcp::Context.new(
|
|
20
|
+
tool_name: tool.name,
|
|
21
|
+
permissions: tool.permissions,
|
|
22
|
+
bypass: config.bypass_permissions,
|
|
23
|
+
credentials: ZeroMcp::Credentials.resolve(tool.name, config, cache: credential_cache, ns: tool.credentials_ns),
|
|
24
|
+
runtime: get_runtime&.call
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# tools: Hash[String, ZeroMcp::Tool] — already-constructed Tool
|
|
32
|
+
# instances, the same object Scanner#load_tool produces.
|
|
33
|
+
# Not raw hashes, and not file paths.
|
|
34
|
+
# config: optional ZeroMcp::Config. Defaults to ZeroMcp::Config.new
|
|
35
|
+
# (in-memory defaults, no zeromcp.config.json file read).
|
|
36
|
+
# When supplied, title:/execute_timeout: are ignored (the
|
|
37
|
+
# config already fully specifies them).
|
|
38
|
+
# title: OpenAPI info.title override. Ignored if config: is given.
|
|
39
|
+
# execute_timeout: default per-tool execute timeout in seconds. Ignored if
|
|
40
|
+
# config: is given.
|
|
41
|
+
# remote: Array of remote MCP servers ({ 'name', 'url', 'auth',
|
|
42
|
+
# 'tools' }) to federate. Ignored if config: is given
|
|
43
|
+
# (config.remote is used). Connects synchronously:
|
|
44
|
+
# create blocks until every remote has answered or failed.
|
|
45
|
+
# Remote tools are served from `mcp` only; they have no
|
|
46
|
+
# route, so they never appear in `routes` or `openapi`.
|
|
47
|
+
# get_runtime: optional callable, arity 0. Its return value becomes
|
|
48
|
+
# ctx.runtime for local tools, on `mcp` tools/call and in
|
|
49
|
+
# context_for.
|
|
50
|
+
def create(tools, config: nil, title: nil, execute_timeout: nil, remote: nil, get_runtime: nil)
|
|
51
|
+
cfg = config || Config.new(title: title, execute_timeout: execute_timeout, remote: remote)
|
|
52
|
+
server = Server.new(cfg, tools: tools, get_runtime: get_runtime)
|
|
53
|
+
merged = cfg.remote.empty? ? tools : server.attach_remote(Remote.connect(cfg.remote))
|
|
54
|
+
credential_cache = {}
|
|
55
|
+
|
|
56
|
+
routes = tools.each_with_object([]) do |(name, tool), acc|
|
|
57
|
+
next unless tool.route.is_a?(Hash)
|
|
58
|
+
acc << RouteDefinition.new(name: name, method: tool.route_method, path: tool.route_path, tool: tool)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ToolRegistry.new(
|
|
62
|
+
routes: routes,
|
|
63
|
+
openapi: OpenApi.build(merged, cfg),
|
|
64
|
+
mcp: ->(request) { server.handle_request(request) },
|
|
65
|
+
config: cfg,
|
|
66
|
+
credential_cache: credential_cache,
|
|
67
|
+
get_runtime: get_runtime
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Top-level convenience, mirroring ZeroMcp.serve.
|
|
73
|
+
def self.create_registry(tools, config: nil, title: nil, execute_timeout: nil, remote: nil, get_runtime: nil)
|
|
74
|
+
Registry.create(tools, config: config, title: title, execute_timeout: execute_timeout,
|
|
75
|
+
remote: remote, get_runtime: get_runtime)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'tool'
|
|
8
|
+
|
|
9
|
+
module ZeroMcp
|
|
10
|
+
# Federates remote MCP servers over HTTP. Each remote tool is exposed
|
|
11
|
+
# locally as "<server name>.<tool name>" and forwards tools/call upstream.
|
|
12
|
+
module Remote
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# servers: Array<Hash> ('name', 'url', 'auth', 'tools'; string or symbol keys)
|
|
16
|
+
# => Hash[String, ZeroMcp::Tool], keyed "<server name>.<tool name>"
|
|
17
|
+
def connect(servers)
|
|
18
|
+
all = {}
|
|
19
|
+
(servers || []).each do |server|
|
|
20
|
+
name = fetch(server, 'name')
|
|
21
|
+
begin
|
|
22
|
+
tools = connect_one(server)
|
|
23
|
+
tools.each do |tool_name, tool|
|
|
24
|
+
ns_name = "#{name}.#{tool_name}"
|
|
25
|
+
all[ns_name] = tool
|
|
26
|
+
$stderr.puts "[mcp] Remote tool: #{ns_name}"
|
|
27
|
+
end
|
|
28
|
+
rescue => e
|
|
29
|
+
$stderr.puts "[mcp] Failed to connect to #{name}: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
all
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# JSON Schema properties/required -> ZeroMcp input hash
|
|
36
|
+
def schema_to_input(schema)
|
|
37
|
+
input = {}
|
|
38
|
+
return input unless schema.is_a?(Hash) && schema['properties'].is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
required = schema['required'].is_a?(Array) ? schema['required'] : []
|
|
41
|
+
schema['properties'].each do |key, prop|
|
|
42
|
+
prop = {} unless prop.is_a?(Hash)
|
|
43
|
+
type = prop['type'] || 'string'
|
|
44
|
+
is_required = required.include?(key)
|
|
45
|
+
|
|
46
|
+
if prop['description'] || !is_required
|
|
47
|
+
entry = { type: type }
|
|
48
|
+
entry[:description] = prop['description'] if prop['description']
|
|
49
|
+
entry[:optional] = true unless is_required
|
|
50
|
+
input[key] = entry
|
|
51
|
+
else
|
|
52
|
+
input[key] = type
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
input
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def connect_one(server)
|
|
59
|
+
name = fetch(server, 'name')
|
|
60
|
+
url = fetch(server, 'url').to_s
|
|
61
|
+
unless url.start_with?('http://', 'https://')
|
|
62
|
+
raise "Remote server \"#{name}\" must have an HTTP/HTTPS URL, got: #{url}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
auth = Config.resolve_auth(fetch(server, 'auth'))
|
|
66
|
+
allow = fetch(server, 'tools')
|
|
67
|
+
|
|
68
|
+
rpc(url, { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, auth)
|
|
69
|
+
list = rpc(url, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, auth)
|
|
70
|
+
|
|
71
|
+
remote_tools = (list.is_a?(Hash) && list['result'].is_a?(Hash) && list['result']['tools']) || []
|
|
72
|
+
remote_tools = remote_tools.select { |rt| allow.include?(rt['name']) } if allow
|
|
73
|
+
|
|
74
|
+
tools = {}
|
|
75
|
+
remote_tools.each do |rt|
|
|
76
|
+
upstream = rt['name']
|
|
77
|
+
tools[upstream] = Tool.new(
|
|
78
|
+
name: "#{name}.#{upstream}",
|
|
79
|
+
description: rt['description'],
|
|
80
|
+
input: schema_to_input(rt['inputSchema']),
|
|
81
|
+
permissions: {},
|
|
82
|
+
route: nil
|
|
83
|
+
) do |args, _ctx|
|
|
84
|
+
res = rpc(url, {
|
|
85
|
+
jsonrpc: '2.0', id: (Time.now.to_f * 1000).to_i,
|
|
86
|
+
method: 'tools/call', params: { name: upstream, arguments: args }
|
|
87
|
+
}, auth)
|
|
88
|
+
result = res.is_a?(Hash) ? res['result'] : nil
|
|
89
|
+
content = result.is_a?(Hash) ? result['content'] : nil
|
|
90
|
+
text = content.is_a?(Array) && content[0].is_a?(Hash) ? content[0]['text'] : nil
|
|
91
|
+
text.nil? || text == '' ? result : text
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
$stderr.puts "[mcp] Connected to #{name} (HTTP): #{tools.size} tool(s)"
|
|
96
|
+
tools
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def rpc(url, body, auth)
|
|
100
|
+
uri = URI(url)
|
|
101
|
+
req = Net::HTTP::Post.new(uri)
|
|
102
|
+
req['Content-Type'] = 'application/json'
|
|
103
|
+
req['Authorization'] = "Bearer #{auth}" if auth
|
|
104
|
+
req.body = JSON.generate(body)
|
|
105
|
+
|
|
106
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |http| http.request(req) }
|
|
107
|
+
raise "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
108
|
+
|
|
109
|
+
JSON.parse(res.body)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fetch(hash, key)
|
|
113
|
+
return nil unless hash.is_a?(Hash)
|
|
114
|
+
hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/zeromcp/scanner.rb
CHANGED
|
@@ -13,38 +13,43 @@ module ZeroMcp
|
|
|
13
13
|
|
|
14
14
|
def scan
|
|
15
15
|
@tools.clear
|
|
16
|
-
dirs = @config.tools_dir
|
|
17
|
-
dirs = [dirs] unless dirs.is_a?(Array)
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
dir = File.expand_path(
|
|
17
|
+
@config.tool_sources.each do |source|
|
|
18
|
+
dir = File.expand_path(source['path'])
|
|
21
19
|
unless Dir.exist?(dir)
|
|
22
20
|
$stderr.puts "[zeromcp] Cannot read tools directory: #{dir}"
|
|
23
21
|
next
|
|
24
22
|
end
|
|
25
|
-
scan_dir(dir, dir)
|
|
23
|
+
scan_dir(dir, dir, source['prefix'])
|
|
26
24
|
end
|
|
27
25
|
@tools
|
|
28
26
|
end
|
|
29
27
|
|
|
28
|
+
# The tool source directories, expanded. Used by the autoload watcher.
|
|
29
|
+
def source_dirs
|
|
30
|
+
@config.tool_sources.map { |s| File.expand_path(s['path']) }
|
|
31
|
+
end
|
|
32
|
+
|
|
30
33
|
private
|
|
31
34
|
|
|
32
|
-
def scan_dir(dir, root_dir)
|
|
35
|
+
def scan_dir(dir, root_dir, source_prefix = nil)
|
|
33
36
|
Dir.entries(dir).sort.each do |entry|
|
|
34
37
|
next if entry.start_with?('.')
|
|
35
38
|
|
|
36
39
|
full_path = File.join(dir, entry)
|
|
37
40
|
|
|
38
41
|
if File.directory?(full_path)
|
|
39
|
-
scan_dir(full_path, root_dir)
|
|
42
|
+
scan_dir(full_path, root_dir, source_prefix)
|
|
40
43
|
elsif entry.end_with?('.rb')
|
|
41
|
-
load_tool(full_path, root_dir)
|
|
44
|
+
load_tool(full_path, root_dir, source_prefix)
|
|
42
45
|
end
|
|
43
46
|
end
|
|
44
47
|
end
|
|
45
48
|
|
|
46
|
-
def load_tool(file_path, root_dir)
|
|
47
|
-
name = build_name(file_path, root_dir)
|
|
49
|
+
def load_tool(file_path, root_dir, source_prefix = nil)
|
|
50
|
+
name = build_name(file_path, root_dir, source_prefix)
|
|
51
|
+
rel_parts = Pathname.new(file_path).relative_path_from(Pathname.new(root_dir)).to_s.split('/')
|
|
52
|
+
credentials_ns = rel_parts.length > 1 ? rel_parts[0] : nil
|
|
48
53
|
|
|
49
54
|
# Each tool file should return a hash via a special structure.
|
|
50
55
|
# We use a sandboxed binding to evaluate the file.
|
|
@@ -57,7 +62,9 @@ module ZeroMcp
|
|
|
57
62
|
name: name,
|
|
58
63
|
description: tool_def[:description] || '',
|
|
59
64
|
input: tool_def[:input] || {},
|
|
60
|
-
permissions: tool_def[:permissions] || {}
|
|
65
|
+
permissions: tool_def[:permissions] || {},
|
|
66
|
+
route: tool_def[:route],
|
|
67
|
+
credentials_ns: credentials_ns
|
|
61
68
|
) { |args, ctx| tool_def[:execute].call(args, ctx) }
|
|
62
69
|
|
|
63
70
|
$stderr.puts "[zeromcp] Loaded: #{name}"
|
|
@@ -72,17 +79,38 @@ module ZeroMcp
|
|
|
72
79
|
loader._tool_definition
|
|
73
80
|
end
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
# inner = filename, or "<dir_prefix><sep><filename>" for a file under a
|
|
83
|
+
# subdir, where dir_prefix is namespacing[<top dir>]['prefix'] when that
|
|
84
|
+
# key is set (an empty string means no prefix) and the dir name otherwise.
|
|
85
|
+
# A source prefix is then prepended: "<source_prefix><sep><inner>".
|
|
86
|
+
def build_name(file_path, root_dir, source_prefix = nil)
|
|
76
87
|
rel = Pathname.new(file_path).relative_path_from(Pathname.new(root_dir)).to_s
|
|
77
88
|
parts = rel.split('/')
|
|
78
89
|
filename = File.basename(parts.pop, '.rb')
|
|
90
|
+
sep = @config.separator
|
|
79
91
|
|
|
92
|
+
inner = filename
|
|
80
93
|
if parts.length > 0
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
filename
|
|
94
|
+
dir = parts[0]
|
|
95
|
+
dir_prefix = namespace_prefix(dir)
|
|
96
|
+
dir_prefix = dir if dir_prefix.nil?
|
|
97
|
+
inner = "#{dir_prefix}#{sep}#{filename}" unless dir_prefix.empty?
|
|
85
98
|
end
|
|
99
|
+
|
|
100
|
+
source_prefix && !source_prefix.empty? ? "#{source_prefix}#{sep}#{inner}" : inner
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# namespacing[dir]['prefix'] (string or symbol keys), or nil when unset.
|
|
104
|
+
def namespace_prefix(dir)
|
|
105
|
+
ns = @config.namespacing
|
|
106
|
+
return nil unless ns.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
entry = ns.key?(dir) ? ns[dir] : ns[dir.to_sym]
|
|
109
|
+
return nil unless entry.is_a?(Hash)
|
|
110
|
+
return entry['prefix'].to_s if entry.key?('prefix') && !entry['prefix'].nil?
|
|
111
|
+
return entry[:prefix].to_s if entry.key?(:prefix) && !entry[:prefix].nil?
|
|
112
|
+
|
|
113
|
+
nil
|
|
86
114
|
end
|
|
87
115
|
|
|
88
116
|
def log_permissions(name, permissions)
|
|
@@ -103,10 +131,11 @@ module ZeroMcp
|
|
|
103
131
|
@definition = {}
|
|
104
132
|
end
|
|
105
133
|
|
|
106
|
-
def tool(description: '', permissions: {}, input: {})
|
|
134
|
+
def tool(description: '', permissions: {}, input: {}, route: nil)
|
|
107
135
|
@definition[:description] = description
|
|
108
136
|
@definition[:permissions] = permissions
|
|
109
137
|
@definition[:input] = input
|
|
138
|
+
@definition[:route] = route
|
|
110
139
|
end
|
|
111
140
|
|
|
112
141
|
def execute(&block)
|
data/lib/zeromcp/server.rb
CHANGED
|
@@ -8,58 +8,67 @@ require_relative 'config'
|
|
|
8
8
|
require_relative 'tool'
|
|
9
9
|
require_relative 'scanner'
|
|
10
10
|
require_relative 'sandbox'
|
|
11
|
+
require_relative 'credentials'
|
|
12
|
+
require_relative 'remote'
|
|
11
13
|
|
|
12
14
|
module ZeroMcp
|
|
13
15
|
class Server
|
|
14
|
-
|
|
16
|
+
HTTP_REMOVED_MESSAGE = '[zeromcp] The built-in HTTP transport was removed. Mount ZeroMcp.create_registry ' \
|
|
17
|
+
'(routes, openapi, mcp) or ZeroMcp::Server#handle_request in your own HTTP server.'
|
|
18
|
+
|
|
19
|
+
# get_runtime: optional callable (lambda/proc/method), arity 0. Called on
|
|
20
|
+
# every local tool execution; its return value becomes ctx.runtime.
|
|
21
|
+
def initialize(config = nil, tools: nil, get_runtime: nil)
|
|
15
22
|
@config = config || Config.load
|
|
16
23
|
@scanner = Scanner.new(@config)
|
|
17
24
|
@resource_scanner = ResourceScanner.new(@config)
|
|
18
25
|
@prompt_scanner = PromptScanner.new(@config)
|
|
19
|
-
@tools = {}
|
|
26
|
+
@tools = tools || {}
|
|
27
|
+
@remote_names = {}
|
|
28
|
+
@get_runtime = get_runtime
|
|
20
29
|
@resources = {}
|
|
21
30
|
@templates = {}
|
|
22
31
|
@prompts = {}
|
|
23
32
|
@subscriptions = {}
|
|
24
33
|
@log_level = 'info'
|
|
25
|
-
@icon = nil
|
|
34
|
+
@icon = tools ? Config.resolve_icon(@config.icon) : nil
|
|
26
35
|
@credential_cache = {}
|
|
36
|
+
@out_mutex = Mutex.new
|
|
37
|
+
@notify = nil
|
|
38
|
+
@autoload_thread = nil
|
|
27
39
|
end
|
|
28
40
|
|
|
29
|
-
# Load tools (and
|
|
41
|
+
# Load tools (remote + local), resources and prompts from the config.
|
|
30
42
|
# Call this before using handle_request directly (serve calls this
|
|
31
|
-
# automatically).
|
|
43
|
+
# automatically). A local scan error propagates.
|
|
32
44
|
def load_tools
|
|
33
|
-
@tools = @scanner.scan
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@templates = @resource_scanner.templates
|
|
37
|
-
@prompt_scanner.scan
|
|
38
|
-
@prompts = @prompt_scanner.prompts
|
|
39
|
-
@icon = Config.resolve_icon(@config.icon)
|
|
45
|
+
@tools = build_tool_map(@scanner.scan)
|
|
46
|
+
load_resources_and_prompts
|
|
47
|
+
end
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
# Merge remote tools under the current tool map: local tools win, logging
|
|
50
|
+
# each override. Returns the merged map. Used by Registry.
|
|
51
|
+
def attach_remote(remote_tools)
|
|
52
|
+
@tools = merge_remote(remote_tools, @tools)
|
|
43
53
|
end
|
|
44
54
|
|
|
45
55
|
def serve
|
|
56
|
+
if @config.http_transport?
|
|
57
|
+
$stderr.puts HTTP_REMOVED_MESSAGE
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
46
61
|
$stdout.sync = true
|
|
47
62
|
$stderr.sync = true
|
|
48
63
|
$stdin.set_encoding('UTF-8')
|
|
49
64
|
$stdout.set_encoding('UTF-8')
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
@resource_scanner.scan
|
|
53
|
-
@resources = @resource_scanner.resources
|
|
54
|
-
@templates = @resource_scanner.templates
|
|
55
|
-
@prompt_scanner.scan
|
|
56
|
-
@prompts = @prompt_scanner.prompts
|
|
57
|
-
@icon = Config.resolve_icon(@config.icon)
|
|
58
|
-
|
|
59
|
-
resource_count = @resources.size + @templates.size
|
|
60
|
-
$stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
|
|
66
|
+
load_for_serve
|
|
61
67
|
$stderr.puts "[zeromcp] stdio transport ready"
|
|
62
68
|
|
|
69
|
+
@notify = ->(method, params) { write_message({ 'jsonrpc' => '2.0', 'method' => method, 'params' => params }) }
|
|
70
|
+
start_autoload if @config.autoload_tools
|
|
71
|
+
|
|
63
72
|
$stdin.each_line do |line|
|
|
64
73
|
begin
|
|
65
74
|
line = line.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').strip
|
|
@@ -77,11 +86,10 @@ module ZeroMcp
|
|
|
77
86
|
next unless request.is_a?(Hash)
|
|
78
87
|
|
|
79
88
|
response = handle_request(request)
|
|
80
|
-
if response
|
|
81
|
-
$stdout.puts JSON.generate(response)
|
|
82
|
-
$stdout.flush
|
|
83
|
-
end
|
|
89
|
+
write_message(response) if response
|
|
84
90
|
end
|
|
91
|
+
ensure
|
|
92
|
+
stop_autoload
|
|
85
93
|
end
|
|
86
94
|
|
|
87
95
|
# Process a single JSON-RPC request hash and return a response hash.
|
|
@@ -423,7 +431,9 @@ module ZeroMcp
|
|
|
423
431
|
end
|
|
424
432
|
|
|
425
433
|
begin
|
|
426
|
-
ctx = Context.new(tool_name: name, permissions: tool.permissions, bypass: @config.bypass_permissions,
|
|
434
|
+
ctx = Context.new(tool_name: name, permissions: tool.permissions, bypass: @config.bypass_permissions,
|
|
435
|
+
credentials: _resolve_credentials(tool, name),
|
|
436
|
+
runtime: @remote_names.key?(name) ? nil : @get_runtime&.call)
|
|
427
437
|
|
|
428
438
|
# Tool-level timeout overrides config default
|
|
429
439
|
timeout_secs = (tool.permissions.is_a?(Hash) && tool.permissions[:execute_timeout]) ||
|
|
@@ -440,38 +450,124 @@ module ZeroMcp
|
|
|
440
450
|
end
|
|
441
451
|
end
|
|
442
452
|
|
|
443
|
-
def _resolve_credentials(tool_name)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
453
|
+
def _resolve_credentials(tool, tool_name)
|
|
454
|
+
Credentials.resolve(tool_name, @config, cache: @credential_cache, ns: tool.credentials_ns)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# --- Loading ---
|
|
458
|
+
|
|
459
|
+
# Remote tools first, then local tools over them (Node: server.ts / handler.ts).
|
|
460
|
+
def build_tool_map(local_tools)
|
|
461
|
+
@remote_names = {}
|
|
462
|
+
remote = @config.remote.empty? ? {} : Remote.connect(@config.remote)
|
|
463
|
+
merge_remote(remote, local_tools)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def merge_remote(remote_tools, local_tools)
|
|
467
|
+
merged = {}
|
|
468
|
+
remote_tools.each { |name, tool| merged[name] = tool }
|
|
469
|
+
local_tools.each do |name, tool|
|
|
470
|
+
$stderr.puts "[zeromcp] Local tool \"#{name}\" overrides remote" if remote_tools.key?(name)
|
|
471
|
+
merged[name] = tool
|
|
472
|
+
end
|
|
473
|
+
remote_tools.each_key { |name| @remote_names[name] = true unless local_tools.key?(name) }
|
|
474
|
+
merged
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# serve's loading path: like load_tools, but a failing local scan only
|
|
478
|
+
# exits when there is nothing else to serve (Node: server.ts).
|
|
479
|
+
def load_for_serve
|
|
480
|
+
local = begin
|
|
481
|
+
@scanner.scan
|
|
482
|
+
rescue StandardError
|
|
483
|
+
if @config.remote.empty? && @config.resources_dir.empty? && @config.prompts_dir.empty?
|
|
484
|
+
$stderr.puts '[zeromcp] No tools directory and no remote servers configured'
|
|
485
|
+
exit 1
|
|
448
486
|
end
|
|
487
|
+
{}
|
|
449
488
|
end
|
|
450
|
-
|
|
489
|
+
@tools = build_tool_map(local)
|
|
490
|
+
load_resources_and_prompts
|
|
451
491
|
end
|
|
452
492
|
|
|
453
|
-
def
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
@
|
|
458
|
-
|
|
493
|
+
def load_resources_and_prompts
|
|
494
|
+
@resource_scanner.scan
|
|
495
|
+
@resources = @resource_scanner.resources
|
|
496
|
+
@templates = @resource_scanner.templates
|
|
497
|
+
@prompt_scanner.scan
|
|
498
|
+
@prompts = @prompt_scanner.prompts
|
|
499
|
+
@icon = Config.resolve_icon(@config.icon)
|
|
500
|
+
|
|
501
|
+
resource_count = @resources.size + @templates.size
|
|
502
|
+
$stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
|
|
459
503
|
end
|
|
460
504
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
505
|
+
# --- Output ---
|
|
506
|
+
|
|
507
|
+
# Stdout is shared by the stdin loop and the autoload watcher thread.
|
|
508
|
+
def write_message(message)
|
|
509
|
+
@out_mutex.synchronize do
|
|
510
|
+
$stdout.puts JSON.generate(message)
|
|
511
|
+
$stdout.flush
|
|
467
512
|
end
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# --- Hot reload (autoload_tools) ---
|
|
516
|
+
|
|
517
|
+
# Ruby's stdlib has no recursive file watcher, so poll mtimes of every
|
|
518
|
+
# .rb file under each tool source. On a change: rescan, set every
|
|
519
|
+
# rescanned tool into the live map (never removing entries, as Node does),
|
|
520
|
+
# and send notifications/tools/list_changed when stdio is running.
|
|
521
|
+
def start_autoload(interval: 1.0)
|
|
522
|
+
snapshot = tool_file_snapshot
|
|
523
|
+
@autoload_thread = Thread.new do
|
|
524
|
+
loop do
|
|
525
|
+
sleep interval
|
|
526
|
+
current = tool_file_snapshot
|
|
527
|
+
changed = (current.keys | snapshot.keys).reject { |k| current[k] == snapshot[k] }
|
|
528
|
+
snapshot_was = snapshot
|
|
529
|
+
snapshot = current
|
|
530
|
+
next if changed.empty?
|
|
531
|
+
|
|
532
|
+
changed.sort.each { |path| $stderr.puts "[zeromcp] Change detected: #{(current[path] || snapshot_was[path])[0]}" }
|
|
533
|
+
reload_tools
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
$stderr.puts "[zeromcp] autoload error: #{e.message}"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
$stderr.puts '[zeromcp] autoload_tools enabled — watching for changes'
|
|
539
|
+
@autoload_thread
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def stop_autoload
|
|
543
|
+
@autoload_thread&.kill
|
|
544
|
+
@autoload_thread = nil
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def reload_tools
|
|
548
|
+
rescanned = @scanner.scan.dup
|
|
549
|
+
tools = @tools.dup
|
|
550
|
+
rescanned.each do |name, tool|
|
|
551
|
+
tools[name] = tool
|
|
552
|
+
@remote_names.delete(name)
|
|
553
|
+
end
|
|
554
|
+
@tools = tools
|
|
555
|
+
@notify&.call('notifications/tools/list_changed', {})
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# { <absolute path> => [<path relative to its source>, mtime] } for every tool file.
|
|
559
|
+
def tool_file_snapshot
|
|
560
|
+
snap = {}
|
|
561
|
+
@scanner.source_dirs.each do |dir|
|
|
562
|
+
next unless Dir.exist?(dir)
|
|
563
|
+
Dir.glob('**/*.rb', base: dir).each do |rel|
|
|
564
|
+
full = File.join(dir, rel)
|
|
565
|
+
snap[full] = [rel, File.mtime(full)]
|
|
566
|
+
rescue StandardError
|
|
567
|
+
next
|
|
568
|
+
end
|
|
473
569
|
end
|
|
474
|
-
|
|
570
|
+
snap
|
|
475
571
|
end
|
|
476
572
|
end
|
|
477
573
|
end
|
data/lib/zeromcp/tool.rb
CHANGED
|
@@ -4,13 +4,20 @@ 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
|
+
:credentials_ns
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
# credentials_ns: the `credentials` config key this tool's credentials come
|
|
11
|
+
# from. Scanner sets it to the tool's top-level directory, so a
|
|
12
|
+
# `namespacing` prefix doesn't change which credentials it gets. nil means
|
|
13
|
+
# match by tool-name prefix.
|
|
14
|
+
def initialize(name:, description: '', input: {}, permissions: {}, route: nil, credentials_ns: nil, &block)
|
|
10
15
|
@name = name
|
|
11
16
|
@description = description
|
|
12
17
|
@input = input
|
|
13
18
|
@permissions = permissions
|
|
19
|
+
@route = route
|
|
20
|
+
@credentials_ns = credentials_ns
|
|
14
21
|
@execute_block = block
|
|
15
22
|
@cached_schema = Schema.to_json_schema(@input)
|
|
16
23
|
end
|
|
@@ -18,16 +25,27 @@ module ZeroMcp
|
|
|
18
25
|
def call(args, ctx = {})
|
|
19
26
|
@execute_block.call(args, ctx)
|
|
20
27
|
end
|
|
28
|
+
|
|
29
|
+
def route_method
|
|
30
|
+
return nil unless @route.is_a?(Hash)
|
|
31
|
+
(@route[:method] || @route['method'] || 'POST').upcase
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def route_path
|
|
35
|
+
return nil unless @route.is_a?(Hash)
|
|
36
|
+
@route[:path] || @route['path'] || '/'
|
|
37
|
+
end
|
|
21
38
|
end
|
|
22
39
|
|
|
23
40
|
class Context
|
|
24
|
-
attr_reader :credentials, :tool_name, :permissions, :bypass
|
|
41
|
+
attr_reader :credentials, :tool_name, :permissions, :bypass, :runtime
|
|
25
42
|
|
|
26
|
-
def initialize(tool_name:, credentials: nil, permissions: {}, bypass: false)
|
|
43
|
+
def initialize(tool_name:, credentials: nil, permissions: {}, bypass: false, runtime: nil)
|
|
27
44
|
@tool_name = tool_name
|
|
28
45
|
@credentials = credentials
|
|
29
46
|
@permissions = permissions
|
|
30
47
|
@bypass = bypass
|
|
48
|
+
@runtime = runtime
|
|
31
49
|
end
|
|
32
50
|
end
|
|
33
51
|
|
data/lib/zeromcp.rb
CHANGED
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.4.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-10-14 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,10 @@ 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
|
|
28
|
+
- lib/zeromcp/remote.rb
|
|
25
29
|
- lib/zeromcp/sandbox.rb
|
|
26
30
|
- lib/zeromcp/scanner.rb
|
|
27
31
|
- lib/zeromcp/schema.rb
|