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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2e5b203cdc7f03afff0cf6ce507e9480455b3f16e469222d5934af9351ffa9a
4
- data.tar.gz: 8e968d22d810a2f25962eb5de0f242438f3ea4359c40cee4616398280695c6f8
3
+ metadata.gz: 4b5b1e9efe4dd7daa0f3ac769bc0989684a322df09a9b6f0886b1f8c9b11be14
4
+ data.tar.gz: b8b9a22497327fdf3618c01d959e01d692a83f6c4785d6e7b655bdd5eacdc8fb
5
5
  SHA512:
6
- metadata.gz: b5af7807f278f6430cd65fad75104d90536fbe5c840a2dfce3b7c125415940d65f4166e420f06b085c1ff6fe60c90a525773956e4e341c3b20098258227296a5
7
- data.tar.gz: 3c0c7c8350efedaf3bf7ec4342e85f817b6da45e6668d6aace1553ef98edc478143db42acc4652b119a179c7b4580ddef29c54226457d03b3ed88d4582e6bd09
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,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. 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'
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
- request_body = JSON.parse(request.body.read)
49
- 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
62
+
63
+ # OpenAPI
64
+ get '/openapi.json' do
65
+ content_type :json
66
+ registry.openapi.to_json
67
+ end
50
68
 
51
- if response.nil?
52
- status 204
53
- else
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
- response.to_json
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.1.0.gem
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/**/*_test.rb"].each { |f| require_relative f }'
178
+ ruby -I lib -I test -e 'Dir["test/**/test_*.rb"].each { |f| require_relative f }'
103
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
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
@@ -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
@@ -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.
@@ -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
- 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)
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
- dir_prefix = parts[0]
82
- "#{dir_prefix}#{@config.separator}#{filename}"
83
- else
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)
@@ -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
- def initialize(config = 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)
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 resources/prompts) from the configured directories.
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
- @resource_scanner.scan
35
- @resources = @resource_scanner.resources
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
- resource_count = @resources.size + @templates.size
42
- $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)
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
- @tools = @scanner.scan
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, 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)
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
- return nil if @config.credentials.empty?
445
- @config.credentials.each do |ns, source|
446
- if tool_name.start_with?("#{ns}_") || tool_name.start_with?("#{ns}#{@config.separator}")
447
- return _resolve_credentials_for_ns(ns.to_s, source)
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
- nil
489
+ @tools = build_tool_map(local)
490
+ load_resources_and_prompts
451
491
  end
452
492
 
453
- def _resolve_credentials_for_ns(ns, source)
454
- return _resolve_credential_source(source) unless @config.cache_credentials
455
- return @credential_cache[ns] if @credential_cache.key?(ns)
456
- creds = _resolve_credential_source(source)
457
- @credential_cache[ns] = creds
458
- creds
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
- def _resolve_credential_source(source)
462
- source = source.transform_keys(&:to_s) if source.is_a?(Hash)
463
- if source['env']
464
- val = ENV[source['env']]
465
- return nil if val.nil? || val.empty?
466
- begin; return JSON.parse(val); rescue; return val; end
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
- if source['file']
469
- path = File.expand_path(source['file'])
470
- return nil unless File.exist?(path)
471
- val = File.read(path).strip
472
- begin; return JSON.parse(val); rescue; return val; end
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
- nil
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
- def initialize(name:, description: '', input: {}, permissions: {}, &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
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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'zeromcp/server'
4
+ require_relative 'zeromcp/remote'
5
+ require_relative 'zeromcp/registry'
4
6
 
5
7
  module ZeroMcp
6
8
  def self.serve(config_path = nil)
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.2.2'
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.2.2
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-04-25 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
@@ -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