Class: Rubino::MCP::Manager
- Inherits:
-
Object
- Object
- Rubino::MCP::Manager
- Defined in:
- lib/rubino/mcp/manager.rb
Overview
Manages multiple MCP client connections. Reads server definitions from config, starts clients, and registers their tools into the agent’s tool registry.
Instance Attribute Summary collapse
-
#clients ⇒ Object
readonly
clients: name => live RubyLLM::MCP client.
-
#last_errors ⇒ Object
readonly
clients: name => live RubyLLM::MCP client.
Instance Method Summary collapse
-
#configured? ⇒ Boolean
Returns true if any MCP servers are configured.
-
#health_check ⇒ Object
Checks health of all connected servers.
-
#initialize(config: nil) ⇒ Manager
constructor
A new instance of Manager.
-
#register_all_tools! ⇒ Object
Registers all MCP tools into the agent’s tool registry.
-
#register_server_tools(name) ⇒ Object
Registers ONE started server’s tools — the ‘/mcp <server> on` path (#182) re-registers only that server instead of re-reading every client’s tool list.
-
#start_all! ⇒ Object
Initializes all configured MCP servers.
-
#start_server(name, server_config) ⇒ Object
Starts a single MCP server by name.
-
#stop_all! ⇒ Object
Stops all MCP clients (deregistering their tools — see #stop_server).
-
#stop_server(name) ⇒ Object
Stops a specific MCP client AND deregisters its MCPToolWrapper instances from Tools::Registry (#182) — before, nothing ever unregistered them, so a stopped server left dead tools the model could still call.
Constructor Details
#initialize(config: nil) ⇒ Manager
Returns a new instance of Manager.
17 18 19 20 21 22 23 24 25 26 |
# File 'lib/rubino/mcp/manager.rb', line 17 def initialize(config: nil) @config = config || Rubino.configuration @clients = {} @last_errors = {} # Guards @clients / @last_errors writes during the PARALLEL connect phase # (start_all!). The single-server path (start_server) takes it too, so the # invariant "shared-state mutations are serialized" holds on every caller. @state_mutex = Mutex.new route_mcp_logging! end |
Instance Attribute Details
#clients ⇒ Object (readonly)
clients: name => live RubyLLM::MCP client. last_errors: name => the most recent start failure message (cleared on a successful start) — the “why is my server missing?” answer /mcp’s drill-in shows (#182).
15 16 17 |
# File 'lib/rubino/mcp/manager.rb', line 15 def clients @clients end |
#last_errors ⇒ Object (readonly)
clients: name => live RubyLLM::MCP client. last_errors: name => the most recent start failure message (cleared on a successful start) — the “why is my server missing?” answer /mcp’s drill-in shows (#182).
15 16 17 |
# File 'lib/rubino/mcp/manager.rb', line 15 def last_errors @last_errors end |
Instance Method Details
#configured? ⇒ Boolean
Returns true if any MCP servers are configured
158 159 160 161 |
# File 'lib/rubino/mcp/manager.rb', line 158 def configured? servers = @config.dig("mcp", "servers") servers.is_a?(Hash) && !servers.empty? end |
#health_check ⇒ Object
Checks health of all connected servers. ‘alive` is process-liveness (the child is up); `degraded` is protocol-liveness (#575): the process is alive but tools/list/registration failed, so a recorded last_error exists despite a live client. Callers render degraded distinctly from plain reachable — an alive server that legitimately exposes zero tools has NO last_error and is NOT degraded.
146 147 148 149 150 151 152 153 154 155 |
# File 'lib/rubino/mcp/manager.rb', line 146 def health_check @clients.map do |name, client| alive = begin client.alive? rescue StandardError false end { name: name, alive: alive, degraded: alive && @last_errors.key?(name.to_s) } end end |
#register_all_tools! ⇒ Object
Registers all MCP tools into the agent’s tool registry. Per-agent mcp_servers scoping is NOT applied here — it lives in Agent::Definition#resolved_tools (#173), the single seam every consumer of an agent’s tool set goes through. Registers in a STABLE order (sorted by server name) rather than @clients’ insertion order — under the parallel start_all! @clients is populated in connect-COMPLETION order, which is nondeterministic. Sorting keeps the resulting tool-registration order (and anything downstream that reads it) deterministic across boots.
113 114 115 |
# File 'lib/rubino/mcp/manager.rb', line 113 def register_all_tools! @clients.keys.sort.each { |server_name| register_server_tools(server_name) } end |
#register_server_tools(name) ⇒ Object
Registers ONE started server’s tools — the ‘/mcp <server> on` path (#182) re-registers only that server instead of re-reading every client’s tool list.
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/rubino/mcp/manager.rb', line 120 def register_server_tools(name) client = @clients[name.to_s] return unless client client.tools.each do |mcp_tool| wrapped = MCPToolWrapper.new(mcp_tool, server_name: name.to_s) Tools::Registry.register(wrapped) end # A clean tools/list clears any prior failure so a recovered server # stops showing degraded (mirrors start_server clearing on success). @last_errors.delete(name.to_s) rescue StandardError => e # Record the failure so /mcp's drill-in (and the degraded glyph below) # can explain a connected-but-toolless server (#575) — start_server # records start failures the same way; a swallowed warning alone left # the broken state invisible. @last_errors[name.to_s] = e. Rubino.ui.warning("Failed to load tools from '#{name}': #{e.}") end |
#start_all! ⇒ Object
Initializes all configured MCP servers.
The connect handshake is the slow part: each RubyLLM::MCP.client(**opts) blocks up to the per-server request_timeout (default 8 s) while it spawns the child / opens the socket and waits for ‘initialize`. Done SERIALLY, N hanging servers cost the SUM of their timeouts (#576 measured 17.6 s with two stalling servers). So we connect every server CONCURRENTLY — one thread each (the count is small and these threads are I/O-bound) — which bounds total connect time to roughly the slowest SINGLE server.
Thread-safety: each thread only does the network/subprocess connect and writes its result into @clients/@last_errors UNDER @state_mutex (plain Hashes are not thread-safe). Tool registration is deferred to the MAIN thread (register_all_tools! below, after every join) because Tools::Registry is a process-wide singleton over a plain Hash and is NOT thread-safe. Best-effort semantics are preserved: start_server rescues per-server, so one server raising/stalling never aborts the others.
45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/rubino/mcp/manager.rb', line 45 def start_all! server_configs = @config.dig("mcp", "servers") || {} threads = server_configs.map do |name, server_config| Thread.new { start_server(name, server_config) } end threads.each(&:join) register_all_tools! @clients end |
#start_server(name, server_config) ⇒ Object
Starts a single MCP server by name
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/rubino/mcp/manager.rb', line 58 def start_server(name, server_config) transport = server_config["transport"] || "stdio" client_opts = (name, transport, server_config) # The slow, blocking connect runs OUTSIDE the lock so concurrent # start_all! threads actually overlap; only the shared-Hash writes are # serialized under @state_mutex. client = RubyLLM::MCP.client(**client_opts) @state_mutex.synchronize do @clients[name.to_s] = client @last_errors.delete(name.to_s) end Rubino.event_bus.emit(:mcp_server_started, name: name) client rescue StandardError => e @state_mutex.synchronize { @last_errors[name.to_s] = e. } Rubino.ui.warning("MCP server '#{name}' failed to start: #{e.}") nil end |
#stop_all! ⇒ Object
Stops all MCP clients (deregistering their tools — see #stop_server). ‘keys.each`, NOT `each_key`: stop_server deletes from @clients, which would raise mid-iteration without the snapshot.
82 83 84 |
# File 'lib/rubino/mcp/manager.rb', line 82 def stop_all! @clients.keys.each { |name| stop_server(name) } # rubocop:disable Style/HashEachMethods end |
#stop_server(name) ⇒ Object
Stops a specific MCP client AND deregisters its MCPToolWrapper instances from Tools::Registry (#182) — before, nothing ever unregistered them, so a stopped server left dead tools the model could still call.
90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/rubino/mcp/manager.rb', line 90 def stop_server(name) client = @clients.delete(name.to_s) return nil unless client deregister_tools(name.to_s) begin client.stop rescue StandardError => e Rubino.ui.warning("Error stopping MCP '#{name}': #{e.}") end Rubino.event_bus.emit(:mcp_server_stopped, name: name) client end |