Class: Rubino::MCP::Manager

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

#clientsObject (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_errorsObject (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

Returns:

  • (Boolean)


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_checkObject

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.message
  Rubino.ui.warning("Failed to load tools from '#{name}': #{e.message}")
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 = build_client_options(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.message }
  Rubino.ui.warning("MCP server '#{name}' failed to start: #{e.message}")
  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.message}")
  end
  Rubino.event_bus.emit(:mcp_server_stopped, name: name)
  client
end