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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa95cc5fe08e2a54b9034a2248b978e1a01919bd3cb0249f444031e070f0dcd1
4
- data.tar.gz: ae4cd9f4405f8b9786f0bf5e238898b8260d5d37b5a8aac9bdd96ac3ae02876a
3
+ metadata.gz: 4b5b1e9efe4dd7daa0f3ac769bc0989684a322df09a9b6f0886b1f8c9b11be14
4
+ data.tar.gz: b8b9a22497327fdf3618c01d959e01d692a83f6c4785d6e7b655bdd5eacdc8fb
5
5
  SHA512:
6
- metadata.gz: 38b41336ef5617f74cf599c79cb0fd52f872eba7c912825c74020a3026cf8c8e360a1b79ac2f12a612e73cb657e2a2d240a4fed709012796c430990100ddd26f
7
- data.tar.gz: b502b28ab22e0666937aeb772818d6bf8739671c4b274b03acec2307652d64830aa37ee7611094405be58de2c9d134ed67a3ed507241cb8615ea6fe3c90d7def
6
+ metadata.gz: e49425f27d7726ca510f08014d5e5bb9418a5da74ce91f8a7ea746fed9e7c6d7ea9f1a05fe72197bf70c043952143e972707699c41fe3a7308fc490ad669230d
7
+ data.tar.gz: 91328849237941d7ce8aa4a4dd86fb63a8d20c375e40000df390c302ef24e36cd166ca311cc04987fdc7416af51e7a73371ff9b0dfffd08edbcb9919ed20f539
data/Gemfile CHANGED
@@ -6,4 +6,5 @@ gemspec
6
6
 
7
7
  group :test do
8
8
  gem 'minitest', '~> 5.0'
9
+ gem 'webrick' # fake upstream servers in tests only; the gem has no HTTP dependency
9
10
  end
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. You bring your own framework; ZeroMCP gives you a `handle_request` method that takes a Hash and returns a Hash (or `nil` for notifications).
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
- ```ruby
38
- # response = server.handle_request(request)
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
- require_relative 'lib/zeromcp'
49
+ require 'zeromcp'
47
50
 
48
51
  config = ZeroMcp::Config.load
49
- server = ZeroMcp::Server.new(config)
50
- server.load_tools
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
- request_body = JSON.parse(request.body.read)
54
- response = server.handle_request(request_body)
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
- if response.nil?
57
- status 204
58
- else
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
- response.to_json
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
  ```
@@ -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
- @tools_dir = tools.is_a?(Array) ? tools : [tools]
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.is_a?(Array) ? 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.is_a?(Array) ? 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',
@@ -6,11 +6,18 @@ module ZeroMcp
6
6
  module Credentials
7
7
  module_function
8
8
 
9
- def resolve(tool_name, config, cache: nil)
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
- 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)
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
@@ -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
- 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
+ # 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(tools, cfg),
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 / 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)
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
@@ -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
- dirs.each do |d|
20
- dir = File.expand_path(d)
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
- def build_name(file_path, root_dir)
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
- dir_prefix = parts[0]
83
- "#{dir_prefix}#{@config.separator}#{filename}"
84
- else
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)
@@ -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
- def initialize(config = nil, tools: nil)
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
- # 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
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
- @resource_scanner.scan
93
- @resources = @resource_scanner.resources
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
- resource_count = @resources.size + @templates.size
100
- $stderr.puts "[zeromcp] #{@tools.size} tool(s), #{resource_count} resource(s), #{@prompts.size} prompt(s)"
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
- @tools = @scanner.scan
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, credentials: _resolve_credentials(name))
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
- # --- HTTP route helpers ---
457
+ # --- Loading ---
506
458
 
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
- '([^/]+)'
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
- http.mount_proc(route_path_prefix(route_path)) do |req, res|
517
- next unless req.request_method == route_method
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
- 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
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
- 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
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
- 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
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
- 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 || '/'
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
- 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
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
- # --- OpenAPI spec builder ---
542
+ def stop_autoload
543
+ @autoload_thread&.kill
544
+ @autoload_thread = nil
545
+ end
571
546
 
572
- def build_openapi_spec
573
- OpenApi.build(@tools, @config)
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
- 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
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
- def initialize(name:, description: '', input: {}, permissions: {}, route: nil, &block)
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'zeromcp'
5
- s.version = '0.3.0'
5
+ s.version = '0.4.0'
6
6
  s.summary = 'Zero-config MCP runtime'
7
7
  s.description = 'Drop tool files in a directory, get a working MCP server. Zero boilerplate.'
8
8
  s.authors = ['Antidrift']
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.3.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-08-06 00:00:00.000000000 Z
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