zeromcp 0.3.0 → 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 +84 -13
- data/lib/zeromcp/config.rb +64 -5
- data/lib/zeromcp/credentials.rb +11 -4
- data/lib/zeromcp/registry.rb +26 -11
- data/lib/zeromcp/remote.rb +117 -0
- data/lib/zeromcp/scanner.rb +43 -16
- data/lib/zeromcp/server.rb +138 -158
- data/lib/zeromcp/tool.rb +11 -4
- data/lib/zeromcp.rb +1 -6
- data/zeromcp.gemspec +1 -1
- metadata +3 -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,36 +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'
|
|
46
|
-
|
|
49
|
+
require 'zeromcp'
|
|
47
50
|
|
|
48
51
|
config = ZeroMcp::Config.load
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
tools = ZeroMcp::Scanner.new(config).scan
|
|
53
|
+
registry = ZeroMcp.create_registry(tools, config: config)
|
|
51
54
|
|
|
55
|
+
# MCP over HTTP
|
|
52
56
|
post '/mcp' do
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
# OpenAPI
|
|
64
|
+
get '/openapi.json' do
|
|
65
|
+
content_type :json
|
|
66
|
+
registry.openapi.to_json
|
|
67
|
+
end
|
|
68
|
+
|
|
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))
|
|
59
76
|
content_type :json
|
|
60
|
-
|
|
77
|
+
{ ok: true, result: result }.to_json
|
|
61
78
|
end
|
|
62
79
|
end
|
|
63
80
|
```
|
|
64
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
|
+
|
|
65
134
|
## Requirements
|
|
66
135
|
|
|
67
136
|
- Ruby 3.0+
|
|
@@ -103,6 +172,8 @@ tools/
|
|
|
103
172
|
|
|
104
173
|
## Testing
|
|
105
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
|
+
|
|
106
177
|
```sh
|
|
107
178
|
ruby -I lib -I test -e 'Dir["test/**/test_*.rb"].each { |f| require_relative f }'
|
|
108
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, :title
|
|
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
|
|
@@ -29,6 +32,9 @@ module ZeroMcp
|
|
|
29
32
|
@page_size = opts[:page_size] || opts['page_size'] || 0
|
|
30
33
|
@icon = opts[:icon] || opts['icon']
|
|
31
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']
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
attr_reader :credentials, :cache_credentials, :namespacing
|
|
@@ -44,13 +50,53 @@ module ZeroMcp
|
|
|
44
50
|
new
|
|
45
51
|
end
|
|
46
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
|
+
|
|
47
91
|
# Resolve an icon config value to a data URI. Supports:
|
|
48
92
|
# - data: URIs (returned as-is)
|
|
93
|
+
# - http(s) URLs (fetched and base64 encoded)
|
|
49
94
|
# - Local file paths (read and base64 encode)
|
|
50
95
|
# Returns nil on failure or if icon is not set.
|
|
51
96
|
def self.resolve_icon(icon)
|
|
52
97
|
return nil if icon.nil? || icon.empty?
|
|
53
98
|
return icon if icon.start_with?('data:')
|
|
99
|
+
return fetch_icon(icon) if icon.start_with?('http://', 'https://')
|
|
54
100
|
|
|
55
101
|
# File path
|
|
56
102
|
path = File.expand_path(icon)
|
|
@@ -65,6 +111,19 @@ module ZeroMcp
|
|
|
65
111
|
nil
|
|
66
112
|
end
|
|
67
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
|
+
|
|
68
127
|
ICON_MIME = {
|
|
69
128
|
'.png' => 'image/png',
|
|
70
129
|
'.jpg' => 'image/jpeg',
|
data/lib/zeromcp/credentials.rb
CHANGED
|
@@ -6,11 +6,18 @@ module ZeroMcp
|
|
|
6
6
|
module Credentials
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
|
-
|
|
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)
|
|
10
12
|
return nil if config.credentials.empty?
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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)
|
|
14
21
|
end
|
|
15
22
|
end
|
|
16
23
|
nil
|
data/lib/zeromcp/registry.rb
CHANGED
|
@@ -3,22 +3,25 @@
|
|
|
3
3
|
require_relative 'server'
|
|
4
4
|
require_relative 'openapi'
|
|
5
5
|
require_relative 'credentials'
|
|
6
|
+
require_relative 'remote'
|
|
6
7
|
|
|
7
8
|
module ZeroMcp
|
|
8
9
|
module Registry
|
|
9
10
|
RouteDefinition = Struct.new(:name, :method, :path, :tool, keyword_init: true)
|
|
10
11
|
|
|
11
|
-
ToolRegistry = Struct.new(:routes, :openapi, :mcp, :config, :credential_cache, keyword_init: true) do
|
|
12
|
+
ToolRegistry = Struct.new(:routes, :openapi, :mcp, :config, :credential_cache, :get_runtime, keyword_init: true) do
|
|
12
13
|
# Build a ready-to-use Context for `tool`, using the same config-driven
|
|
13
14
|
# credential/permission rules Server#call_tool already applies — so a
|
|
14
15
|
# caller invoking a routed tool directly (route.tool.call(args, ctx))
|
|
15
|
-
# doesn't have to hand-roll credential lookup.
|
|
16
|
+
# doesn't have to hand-roll credential lookup. ctx.runtime comes from
|
|
17
|
+
# the registry's get_runtime hook, when one was given.
|
|
16
18
|
def context_for(tool)
|
|
17
19
|
ZeroMcp::Context.new(
|
|
18
20
|
tool_name: tool.name,
|
|
19
21
|
permissions: tool.permissions,
|
|
20
22
|
bypass: config.bypass_permissions,
|
|
21
|
-
credentials: ZeroMcp::Credentials.resolve(tool.name, config, cache: credential_cache)
|
|
23
|
+
credentials: ZeroMcp::Credentials.resolve(tool.name, config, cache: credential_cache, ns: tool.credentials_ns),
|
|
24
|
+
runtime: get_runtime&.call
|
|
22
25
|
)
|
|
23
26
|
end
|
|
24
27
|
end
|
|
@@ -35,9 +38,19 @@ module ZeroMcp
|
|
|
35
38
|
# title: OpenAPI info.title override. Ignored if config: is given.
|
|
36
39
|
# execute_timeout: default per-tool execute timeout in seconds. Ignored if
|
|
37
40
|
# config: is given.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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))
|
|
41
54
|
credential_cache = {}
|
|
42
55
|
|
|
43
56
|
routes = tools.each_with_object([]) do |(name, tool), acc|
|
|
@@ -47,16 +60,18 @@ module ZeroMcp
|
|
|
47
60
|
|
|
48
61
|
ToolRegistry.new(
|
|
49
62
|
routes: routes,
|
|
50
|
-
openapi: OpenApi.build(
|
|
63
|
+
openapi: OpenApi.build(merged, cfg),
|
|
51
64
|
mcp: ->(request) { server.handle_request(request) },
|
|
52
65
|
config: cfg,
|
|
53
|
-
credential_cache: credential_cache
|
|
66
|
+
credential_cache: credential_cache,
|
|
67
|
+
get_runtime: get_runtime
|
|
54
68
|
)
|
|
55
69
|
end
|
|
56
70
|
end
|
|
57
71
|
|
|
58
|
-
# Top-level convenience, mirroring ZeroMcp.serve
|
|
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
|
|
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)
|
|
61
76
|
end
|
|
62
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.
|
|
@@ -58,7 +63,8 @@ module ZeroMcp
|
|
|
58
63
|
description: tool_def[:description] || '',
|
|
59
64
|
input: tool_def[:input] || {},
|
|
60
65
|
permissions: tool_def[:permissions] || {},
|
|
61
|
-
route: tool_def[:route]
|
|
66
|
+
route: tool_def[:route],
|
|
67
|
+
credentials_ns: credentials_ns
|
|
62
68
|
) { |args, ctx| tool_def[:execute].call(args, ctx) }
|
|
63
69
|
|
|
64
70
|
$stderr.puts "[zeromcp] Loaded: #{name}"
|
|
@@ -73,17 +79,38 @@ module ZeroMcp
|
|
|
73
79
|
loader._tool_definition
|
|
74
80
|
end
|
|
75
81
|
|
|
76
|
-
|
|
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)
|
|
77
87
|
rel = Pathname.new(file_path).relative_path_from(Pathname.new(root_dir)).to_s
|
|
78
88
|
parts = rel.split('/')
|
|
79
89
|
filename = File.basename(parts.pop, '.rb')
|
|
90
|
+
sep = @config.separator
|
|
80
91
|
|
|
92
|
+
inner = filename
|
|
81
93
|
if parts.length > 0
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
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?
|
|
86
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
|
|
87
114
|
end
|
|
88
115
|
|
|
89
116
|
def log_permissions(name, permissions)
|
data/lib/zeromcp/server.rb
CHANGED
|
@@ -3,23 +3,29 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'base64'
|
|
5
5
|
require 'timeout'
|
|
6
|
-
require 'webrick'
|
|
7
6
|
require_relative 'schema'
|
|
8
7
|
require_relative 'config'
|
|
9
8
|
require_relative 'tool'
|
|
10
9
|
require_relative 'scanner'
|
|
11
10
|
require_relative 'sandbox'
|
|
12
|
-
require_relative 'openapi'
|
|
13
11
|
require_relative 'credentials'
|
|
12
|
+
require_relative 'remote'
|
|
14
13
|
|
|
15
14
|
module ZeroMcp
|
|
16
15
|
class Server
|
|
17
|
-
|
|
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)
|
|
18
22
|
@config = config || Config.load
|
|
19
23
|
@scanner = Scanner.new(@config)
|
|
20
24
|
@resource_scanner = ResourceScanner.new(@config)
|
|
21
25
|
@prompt_scanner = PromptScanner.new(@config)
|
|
22
26
|
@tools = tools || {}
|
|
27
|
+
@remote_names = {}
|
|
28
|
+
@get_runtime = get_runtime
|
|
23
29
|
@resources = {}
|
|
24
30
|
@templates = {}
|
|
25
31
|
@prompts = {}
|
|
@@ -27,97 +33,42 @@ module ZeroMcp
|
|
|
27
33
|
@log_level = 'info'
|
|
28
34
|
@icon = tools ? Config.resolve_icon(@config.icon) : nil
|
|
29
35
|
@credential_cache = {}
|
|
36
|
+
@out_mutex = Mutex.new
|
|
37
|
+
@notify = nil
|
|
38
|
+
@autoload_thread = nil
|
|
30
39
|
end
|
|
31
40
|
|
|
32
|
-
#
|
|
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
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Load tools (and resources/prompts) from the configured directories.
|
|
41
|
+
# Load tools (remote + local), resources and prompts from the config.
|
|
88
42
|
# Call this before using handle_request directly (serve calls this
|
|
89
|
-
# automatically).
|
|
43
|
+
# automatically). A local scan error propagates.
|
|
90
44
|
def load_tools
|
|
91
|
-
@tools = @scanner.scan
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@templates = @resource_scanner.templates
|
|
95
|
-
@prompt_scanner.scan
|
|
96
|
-
@prompts = @prompt_scanner.prompts
|
|
97
|
-
@icon = Config.resolve_icon(@config.icon)
|
|
45
|
+
@tools = build_tool_map(@scanner.scan)
|
|
46
|
+
load_resources_and_prompts
|
|
47
|
+
end
|
|
98
48
|
|
|
99
|
-
|
|
100
|
-
|
|
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)
|
|
101
53
|
end
|
|
102
54
|
|
|
103
55
|
def serve
|
|
56
|
+
if @config.http_transport?
|
|
57
|
+
$stderr.puts HTTP_REMOVED_MESSAGE
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
104
61
|
$stdout.sync = true
|
|
105
62
|
$stderr.sync = true
|
|
106
63
|
$stdin.set_encoding('UTF-8')
|
|
107
64
|
$stdout.set_encoding('UTF-8')
|
|
108
65
|
|
|
109
|
-
|
|
110
|
-
@resource_scanner.scan
|
|
111
|
-
@resources = @resource_scanner.resources
|
|
112
|
-
@templates = @resource_scanner.templates
|
|
113
|
-
@prompt_scanner.scan
|
|
114
|
-
@prompts = @prompt_scanner.prompts
|
|
115
|
-
@icon = Config.resolve_icon(@config.icon)
|
|
116
|
-
|
|
117
|
-
resource_count = @resources.size + @templates.size
|
|
118
|
-
$stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
|
|
66
|
+
load_for_serve
|
|
119
67
|
$stderr.puts "[zeromcp] stdio transport ready"
|
|
120
68
|
|
|
69
|
+
@notify = ->(method, params) { write_message({ 'jsonrpc' => '2.0', 'method' => method, 'params' => params }) }
|
|
70
|
+
start_autoload if @config.autoload_tools
|
|
71
|
+
|
|
121
72
|
$stdin.each_line do |line|
|
|
122
73
|
begin
|
|
123
74
|
line = line.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').strip
|
|
@@ -135,11 +86,10 @@ module ZeroMcp
|
|
|
135
86
|
next unless request.is_a?(Hash)
|
|
136
87
|
|
|
137
88
|
response = handle_request(request)
|
|
138
|
-
if response
|
|
139
|
-
$stdout.puts JSON.generate(response)
|
|
140
|
-
$stdout.flush
|
|
141
|
-
end
|
|
89
|
+
write_message(response) if response
|
|
142
90
|
end
|
|
91
|
+
ensure
|
|
92
|
+
stop_autoload
|
|
143
93
|
end
|
|
144
94
|
|
|
145
95
|
# Process a single JSON-RPC request hash and return a response hash.
|
|
@@ -481,7 +431,9 @@ module ZeroMcp
|
|
|
481
431
|
end
|
|
482
432
|
|
|
483
433
|
begin
|
|
484
|
-
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)
|
|
485
437
|
|
|
486
438
|
# Tool-level timeout overrides config default
|
|
487
439
|
timeout_secs = (tool.permissions.is_a?(Hash) && tool.permissions[:execute_timeout]) ||
|
|
@@ -498,96 +450,124 @@ module ZeroMcp
|
|
|
498
450
|
end
|
|
499
451
|
end
|
|
500
452
|
|
|
501
|
-
def _resolve_credentials(tool_name)
|
|
502
|
-
Credentials.resolve(tool_name, @config, cache: @credential_cache)
|
|
453
|
+
def _resolve_credentials(tool, tool_name)
|
|
454
|
+
Credentials.resolve(tool_name, @config, cache: @credential_cache, ns: tool.credentials_ns)
|
|
503
455
|
end
|
|
504
456
|
|
|
505
|
-
# ---
|
|
457
|
+
# --- Loading ---
|
|
506
458
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
end
|
|
514
|
-
route_regex = /\A#{regex_str}\z/
|
|
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
|
|
515
465
|
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
518
476
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
525
486
|
end
|
|
487
|
+
{}
|
|
488
|
+
end
|
|
489
|
+
@tools = build_tool_map(local)
|
|
490
|
+
load_resources_and_prompts
|
|
491
|
+
end
|
|
526
492
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
{}
|
|
535
|
-
end
|
|
536
|
-
body_args.merge(path_params)
|
|
537
|
-
end
|
|
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)
|
|
538
500
|
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
554
|
-
end
|
|
501
|
+
resource_count = @resources.size + @templates.size
|
|
502
|
+
$stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
|
|
555
503
|
end
|
|
556
504
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
512
|
+
end
|
|
560
513
|
end
|
|
561
514
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
|
568
540
|
end
|
|
569
541
|
|
|
570
|
-
|
|
542
|
+
def stop_autoload
|
|
543
|
+
@autoload_thread&.kill
|
|
544
|
+
@autoload_thread = nil
|
|
545
|
+
end
|
|
571
546
|
|
|
572
|
-
def
|
|
573
|
-
|
|
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', {})
|
|
574
556
|
end
|
|
575
557
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
</html>
|
|
591
|
-
HTML
|
|
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
|
|
569
|
+
end
|
|
570
|
+
snap
|
|
571
|
+
end
|
|
592
572
|
end
|
|
593
573
|
end
|
data/lib/zeromcp/tool.rb
CHANGED
|
@@ -4,14 +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, :route
|
|
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
|
|
14
19
|
@route = route
|
|
20
|
+
@credentials_ns = credentials_ns
|
|
15
21
|
@execute_block = block
|
|
16
22
|
@cached_schema = Schema.to_json_schema(@input)
|
|
17
23
|
end
|
|
@@ -32,13 +38,14 @@ module ZeroMcp
|
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
class Context
|
|
35
|
-
attr_reader :credentials, :tool_name, :permissions, :bypass
|
|
41
|
+
attr_reader :credentials, :tool_name, :permissions, :bypass, :runtime
|
|
36
42
|
|
|
37
|
-
def initialize(tool_name:, credentials: nil, permissions: {}, bypass: false)
|
|
43
|
+
def initialize(tool_name:, credentials: nil, permissions: {}, bypass: false, runtime: nil)
|
|
38
44
|
@tool_name = tool_name
|
|
39
45
|
@credentials = credentials
|
|
40
46
|
@permissions = permissions
|
|
41
47
|
@bypass = bypass
|
|
48
|
+
@runtime = runtime
|
|
42
49
|
end
|
|
43
50
|
end
|
|
44
51
|
|
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/remote'
|
|
4
5
|
require_relative 'zeromcp/registry'
|
|
5
6
|
|
|
6
7
|
module ZeroMcp
|
|
@@ -9,10 +10,4 @@ module ZeroMcp
|
|
|
9
10
|
server = Server.new(config)
|
|
10
11
|
server.serve
|
|
11
12
|
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
|
|
18
13
|
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.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
|
|
@@ -25,6 +25,7 @@ files:
|
|
|
25
25
|
- lib/zeromcp/credentials.rb
|
|
26
26
|
- lib/zeromcp/openapi.rb
|
|
27
27
|
- lib/zeromcp/registry.rb
|
|
28
|
+
- lib/zeromcp/remote.rb
|
|
28
29
|
- lib/zeromcp/sandbox.rb
|
|
29
30
|
- lib/zeromcp/scanner.rb
|
|
30
31
|
- lib/zeromcp/schema.rb
|