robot_lab 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +28 -18
- data/lib/robot_lab/errors.rb +4 -0
- data/lib/robot_lab/hook_context.rb +9 -2
- data/lib/robot_lab/mcp/registry.rb +99 -0
- data/lib/robot_lab/mcp/server.rb +47 -83
- data/lib/robot_lab/mcp/server_discovery.rb +22 -9
- data/lib/robot_lab/mcp.rb +259 -0
- data/lib/robot_lab/robot/mcp_management.rb +159 -105
- data/lib/robot_lab/robot/tool_call_gating.rb +144 -0
- data/lib/robot_lab/robot.rb +72 -99
- data/lib/robot_lab/run_config.rb +3 -2
- data/lib/robot_lab/tool.rb +8 -23
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +1 -4
- metadata +14 -40
- data/lib/robot_lab/mcp/client.rb +0 -223
- data/lib/robot_lab/mcp/connection_poller.rb +0 -195
- data/lib/robot_lab/mcp/transports/base.rb +0 -64
- data/lib/robot_lab/mcp/transports/sse.rb +0 -120
- data/lib/robot_lab/mcp/transports/stdio.rb +0 -170
- data/lib/robot_lab/mcp/transports/streamable_http.rb +0 -141
- data/lib/robot_lab/mcp/transports/websocket.rb +0 -111
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# robot_lab's thin layer over ruby_llm's built-in MCP client.
|
|
5
|
+
#
|
|
6
|
+
# ruby_llm 2.1 ships +RubyLLM::MCP+: a class that describes one Model
|
|
7
|
+
# Context Protocol server (stdio or Streamable HTTP), connects lazily, and
|
|
8
|
+
# exposes the server's tools, resources, prompts, OAuth flow, elicitation
|
|
9
|
+
# and long-running tasks. robot_lab adds only what a robot needs on top:
|
|
10
|
+
#
|
|
11
|
+
# - {MCP.build} turns a robot_lab config Hash (the shape used in templates,
|
|
12
|
+
# +RunConfig#mcp+ and +RobotLab.build(mcp: [...])+) into a +RubyLLM::MCP+
|
|
13
|
+
# instance, and passes instances and subclasses through unchanged.
|
|
14
|
+
# - {MCP::Server} is the +RubyLLM::MCP+ subclass those hashes become; it
|
|
15
|
+
# carries a +description+ for {MCP::ServerDiscovery} and a +connected?+ flag.
|
|
16
|
+
# - {MCP::Registry} holds a robot's servers by name so repeated runs reuse
|
|
17
|
+
# one connection (and one stdio process) per server.
|
|
18
|
+
#
|
|
19
|
+
# @example A config hash
|
|
20
|
+
# RobotLab::MCP.build(
|
|
21
|
+
# name: "filesystem",
|
|
22
|
+
# transport: { type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."] },
|
|
23
|
+
# timeout: 30, only: %w[read_file list_directory]
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
# @example A RubyLLM::MCP subclass
|
|
27
|
+
# class GitHub < RubyLLM::MCP
|
|
28
|
+
# command "github-mcp-server", "stdio"
|
|
29
|
+
# env GITHUB_PERSONAL_ACCESS_TOKEN: ENV["GITHUB_PERSONAL_ACCESS_TOKEN"]
|
|
30
|
+
# requires_approval if: :destructive?
|
|
31
|
+
# end
|
|
32
|
+
# RobotLab.build(name: "dev", mcp: [GitHub])
|
|
33
|
+
module MCP
|
|
34
|
+
# Transport types that map onto ruby_llm's Streamable HTTP client.
|
|
35
|
+
HTTP_TYPES = %w[streamable-http streamable_http http https].freeze
|
|
36
|
+
|
|
37
|
+
# Transport types the old robot_lab client offered that ruby_llm does not.
|
|
38
|
+
UNSUPPORTED_TYPES = %w[sse ws websocket].freeze
|
|
39
|
+
|
|
40
|
+
# Keys consumers attach for their own routing/grouping; never errors.
|
|
41
|
+
IGNORED_KEYS = %i[topics independent group session_id auth_provider].freeze
|
|
42
|
+
|
|
43
|
+
# Keys that may appear nested under +transport:+ or flat at the top level.
|
|
44
|
+
TRANSPORT_KEYS = %i[type command args env url headers bearer_token oauth directory cwd].freeze
|
|
45
|
+
|
|
46
|
+
# Keys the factory understands at the top level of a config hash.
|
|
47
|
+
SETTING_KEYS = %i[name description transport timeout prefix only except
|
|
48
|
+
requires_approval defer log_level input_requests extensions].freeze
|
|
49
|
+
|
|
50
|
+
# Errors a server can raise while connecting or listing tools. A robot
|
|
51
|
+
# rescues these per server so one dead server does not abort a run.
|
|
52
|
+
CONNECT_ERRORS = [RubyLLM::Error, RubyLLM::ConfigurationError, SystemCallError, IOError, ArgumentError].freeze
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# Build a server from a config Hash, or pass through an instance or class.
|
|
57
|
+
#
|
|
58
|
+
# @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
|
|
59
|
+
# @return [RubyLLM::MCP]
|
|
60
|
+
# @raise [MCPError] for an unusable spec
|
|
61
|
+
def build(spec)
|
|
62
|
+
case spec
|
|
63
|
+
when RubyLLM::MCP then spec
|
|
64
|
+
when Class
|
|
65
|
+
raise MCPError, "#{spec} is not a RubyLLM::MCP subclass" unless spec <= RubyLLM::MCP
|
|
66
|
+
|
|
67
|
+
spec.new
|
|
68
|
+
when Hash then define(spec).new
|
|
69
|
+
else
|
|
70
|
+
raise MCPError, "Cannot build an MCP server from #{spec.class}; " \
|
|
71
|
+
'pass a Hash, a RubyLLM::MCP, or a RubyLLM::MCP subclass'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Turn a config Hash into an anonymous {Server} subclass with ruby_llm's
|
|
76
|
+
# class-level DSL applied. Public so hosts can build classes once and
|
|
77
|
+
# instantiate them per request (ruby_llm's recommended pattern).
|
|
78
|
+
#
|
|
79
|
+
# @param config [Hash]
|
|
80
|
+
# @return [Class<Server>]
|
|
81
|
+
# @raise [MCPError] when the hash has no name, no usable transport, or an unsupported one
|
|
82
|
+
def define(config)
|
|
83
|
+
config = normalize(config)
|
|
84
|
+
klass = Class.new(Server)
|
|
85
|
+
apply_settings(klass, config)
|
|
86
|
+
klass
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The name a spec will be registered under.
|
|
90
|
+
#
|
|
91
|
+
# @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
|
|
92
|
+
# @return [String]
|
|
93
|
+
# @raise [MCPError] when the spec has no name
|
|
94
|
+
def name_of(spec)
|
|
95
|
+
name = case spec
|
|
96
|
+
when Hash then spec[:name] || spec['name']
|
|
97
|
+
when Class then spec.default_name
|
|
98
|
+
when RubyLLM::MCP then spec.name
|
|
99
|
+
end
|
|
100
|
+
raise MCPError, "MCP server spec has no name: #{spec.inspect}" if name.nil? || name.to_s.strip.empty?
|
|
101
|
+
|
|
102
|
+
name.to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Normalize a timeout to seconds. Values of 1000 or more are taken to be
|
|
106
|
+
# milliseconds (the convention aia and older configs used); the result is
|
|
107
|
+
# never below one second.
|
|
108
|
+
#
|
|
109
|
+
# @param value [Numeric, String]
|
|
110
|
+
# @return [Float]
|
|
111
|
+
def seconds(value)
|
|
112
|
+
secs = value.to_f
|
|
113
|
+
secs /= 1000.0 if secs >= 1000
|
|
114
|
+
[secs, 1.0].max
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# --- internals (module_function keeps them callable as MCP.xxx for the Class.new body) ---
|
|
118
|
+
|
|
119
|
+
# Symbolize keys, fold flat transport keys into +:transport+, require a name.
|
|
120
|
+
# :reek:TooManyStatements -- one linear normalization pass over the hash shape.
|
|
121
|
+
def normalize(config)
|
|
122
|
+
config = config.to_h.transform_keys(&:to_sym)
|
|
123
|
+
name_of(config)
|
|
124
|
+
|
|
125
|
+
raw = config[:transport]
|
|
126
|
+
transport = case raw
|
|
127
|
+
when Hash then raw.transform_keys(&:to_sym)
|
|
128
|
+
when String, Symbol then { type: raw.to_s }
|
|
129
|
+
when nil then {}
|
|
130
|
+
else raw # a transport object (duck type)
|
|
131
|
+
end
|
|
132
|
+
if transport.is_a?(Hash)
|
|
133
|
+
TRANSPORT_KEYS.each { |key| transport[key] = config[key] if config.key?(key) && !transport.key?(key) }
|
|
134
|
+
end
|
|
135
|
+
config.merge(transport: transport)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def apply_settings(klass, config)
|
|
139
|
+
klass.default_name = config[:name].to_s
|
|
140
|
+
klass.description(config[:description]) if config[:description]
|
|
141
|
+
apply_transport(klass, config)
|
|
142
|
+
apply_tool_shaping(klass, config)
|
|
143
|
+
log_ignored_keys(config)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# :reek:TooManyStatements -- each optional setting is one guarded DSL call.
|
|
147
|
+
def apply_tool_shaping(klass, config)
|
|
148
|
+
klass.timeout(seconds(config[:timeout])) if config[:timeout]
|
|
149
|
+
klass.prefix(config[:prefix]) if config[:prefix]
|
|
150
|
+
klass.only(*Array(config[:only])) if config[:only]
|
|
151
|
+
klass.except(*Array(config[:except])) if config[:except]
|
|
152
|
+
klass.log_level(config[:log_level]) if config[:log_level]
|
|
153
|
+
klass.input_requests(*Array(config[:input_requests])) if config.key?(:input_requests)
|
|
154
|
+
apply_extensions(klass, config[:extensions])
|
|
155
|
+
apply_approvals(klass, config[:requires_approval])
|
|
156
|
+
apply_deferral(klass, config[:defer])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def apply_transport(klass, config)
|
|
160
|
+
transport = config[:transport]
|
|
161
|
+
return klass.transport(transport) if transport_object?(transport)
|
|
162
|
+
|
|
163
|
+
case (type = transport_type(config, transport))
|
|
164
|
+
when 'stdio' then apply_stdio(klass, config, transport)
|
|
165
|
+
when *HTTP_TYPES then apply_http(klass, config, transport)
|
|
166
|
+
when *UNSUPPORTED_TYPES then raise MCPError, unsupported_transport_message(config[:name], type)
|
|
167
|
+
else raise MCPError, "Unknown MCP transport type '#{type}' for server '#{config[:name]}'"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def transport_object?(transport)
|
|
172
|
+
!transport.is_a?(Hash) && transport.respond_to?(:request) && transport.respond_to?(:close)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def transport_type(config, transport)
|
|
176
|
+
type = transport[:type]&.to_s&.downcase
|
|
177
|
+
return type unless type.nil? || type.empty?
|
|
178
|
+
return 'stdio' if transport[:command]
|
|
179
|
+
return 'streamable-http' if transport[:url]
|
|
180
|
+
|
|
181
|
+
raise MCPError, "MCP server '#{config[:name]}' needs a command (stdio) or a url (Streamable HTTP)"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def apply_stdio(klass, config, transport)
|
|
185
|
+
command = transport[:command]
|
|
186
|
+
raise MCPError, "MCP server '#{config[:name]}' uses stdio but has no command" if command.nil?
|
|
187
|
+
|
|
188
|
+
klass.command(*(Array(command) + Array(transport[:args])).map(&:to_s))
|
|
189
|
+
env = transport[:env]
|
|
190
|
+
klass.env(**env.to_h.transform_keys(&:to_sym)) if env && !env.empty?
|
|
191
|
+
directory = transport[:directory] || transport[:cwd]
|
|
192
|
+
klass.directory(directory.to_s) if directory
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# :reek:TooManyStatements -- url, headers, bearer token and oauth are four independent guarded calls.
|
|
196
|
+
def apply_http(klass, config, transport)
|
|
197
|
+
url = transport[:url]
|
|
198
|
+
raise MCPError, "MCP server '#{config[:name]}' uses Streamable HTTP but has no url" if url.nil?
|
|
199
|
+
|
|
200
|
+
klass.url(url.to_s)
|
|
201
|
+
transport[:headers].to_h.each { |header, value| klass.header(header.to_s, value) }
|
|
202
|
+
klass.bearer_token(transport[:bearer_token]) if transport[:bearer_token]
|
|
203
|
+
apply_oauth(klass, transport[:oauth])
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def apply_oauth(klass, oauth)
|
|
207
|
+
case oauth
|
|
208
|
+
when nil, false then nil
|
|
209
|
+
when true then klass.oauth
|
|
210
|
+
else klass.oauth(**oauth.to_h.transform_keys(&:to_sym))
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def apply_extensions(klass, extensions)
|
|
215
|
+
case extensions
|
|
216
|
+
when nil then nil
|
|
217
|
+
when Hash
|
|
218
|
+
extensions.each { |name, settings| klass.extension(extension_name(name), **settings.to_h.transform_keys(&:to_sym)) }
|
|
219
|
+
else Array(extensions).each { |name| klass.extension(extension_name(name)) }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# ruby_llm takes a Symbol for the extensions it knows (:tasks, :apps) and
|
|
224
|
+
# a vendor-prefixed String for the rest; YAML configs give Strings for both.
|
|
225
|
+
def extension_name(name)
|
|
226
|
+
name.to_s.include?('/') ? name.to_s : name.to_sym
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def apply_approvals(klass, value)
|
|
230
|
+
case value
|
|
231
|
+
when nil, false then nil
|
|
232
|
+
when true then klass.requires_approval
|
|
233
|
+
when Hash then klass.requires_approval(**value.transform_keys(&:to_sym))
|
|
234
|
+
else klass.requires_approval(*Array(value).map(&:to_s))
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def apply_deferral(klass, value)
|
|
239
|
+
case value
|
|
240
|
+
when nil, false then nil
|
|
241
|
+
when true then klass.defer
|
|
242
|
+
else klass.defer(*Array(value).map(&:to_s))
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def log_ignored_keys(config)
|
|
247
|
+
unknown = config.keys - SETTING_KEYS - TRANSPORT_KEYS - IGNORED_KEYS
|
|
248
|
+
return if unknown.empty?
|
|
249
|
+
|
|
250
|
+
RobotLab.config.logger.debug { "MCP server '#{config[:name]}': ignoring unknown keys #{unknown.join(', ')}" }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def unsupported_transport_message(name, type)
|
|
254
|
+
"MCP server '#{name}' uses transport '#{type}', which ruby_llm 2.1 does not provide " \
|
|
255
|
+
'(only stdio and Streamable HTTP). Point the server at its Streamable HTTP endpoint, ' \
|
|
256
|
+
'or pass a transport: object that responds to request/notify/cancel/close.'
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -2,12 +2,108 @@
|
|
|
2
2
|
|
|
3
3
|
module RobotLab
|
|
4
4
|
class Robot < RubyLLM::Agent
|
|
5
|
-
# MCP
|
|
5
|
+
# MCP servers: the robot's registry, per-run server selection, tool
|
|
6
|
+
# collection, and connection failures.
|
|
6
7
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# Servers are +RubyLLM::MCP+ instances (see RobotLab::MCP). They connect
|
|
9
|
+
# lazily on the first +tools+ request and stay connected across runs
|
|
10
|
+
# until #disconnect. Each run attaches the selected servers' tools to the
|
|
11
|
+
# chat with +with_tools+, so the per-run +tools:+ allowlist, +max_tools+
|
|
12
|
+
# cap and +:none+ apply to MCP tools exactly as to local ones.
|
|
13
|
+
#
|
|
14
|
+
# Owns: @mcp_registry, @mcp_failures, @mcp_tool_owners, @run_mcp_servers
|
|
15
|
+
# Reads: @mcp_config, @tools_config, @name, @mcp_discovery, @bus
|
|
16
|
+
# Contract: ivars initialized by initialize_runtime_state
|
|
10
17
|
module MCPManagement
|
|
18
|
+
# The robot's MCP servers, keyed by name. Each entry is a
|
|
19
|
+
# +RubyLLM::MCP+ with the full protocol surface: +tools+, +call+,
|
|
20
|
+
# +resources+, +resource(uri)+, +prompts+, +prompt(name)+,
|
|
21
|
+
# +instructions+, +close+, and the OAuth flow.
|
|
22
|
+
#
|
|
23
|
+
# @return [MCP::Registry]
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# robot.mcp[:github].resources.map(&:uri)
|
|
27
|
+
# robot.run(robot.mcp[:github].prompt("code_review", code: diff))
|
|
28
|
+
def mcp
|
|
29
|
+
@mcp_registry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Register MCP servers on this robot. Accepts config Hashes,
|
|
33
|
+
# +RubyLLM::MCP+ instances and +RubyLLM::MCP+ subclasses, in any mix.
|
|
34
|
+
# Registering a spec already known returns the same server, so hosts
|
|
35
|
+
# may call this on every request. +with_mcp(nil)+ closes and forgets
|
|
36
|
+
# every server.
|
|
37
|
+
#
|
|
38
|
+
# Registered servers are available to runs that select them through
|
|
39
|
+
# the +mcp:+ cascade (+run(..., mcp: :inherit)+ picks up the build-time
|
|
40
|
+
# list; a run-time Array picks exactly those).
|
|
41
|
+
#
|
|
42
|
+
# @param servers [Array<Hash, RubyLLM::MCP, Class<RubyLLM::MCP>>, nil]
|
|
43
|
+
# @return [self]
|
|
44
|
+
def with_mcp(*servers)
|
|
45
|
+
if servers == [nil]
|
|
46
|
+
@mcp_registry.clear
|
|
47
|
+
else
|
|
48
|
+
servers.flatten.compact.each { |spec| @mcp_registry.add(spec) }
|
|
49
|
+
end
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Eagerly register the build-time MCP servers and connect to each.
|
|
54
|
+
# Connections are otherwise made on the first run that selects a
|
|
55
|
+
# server. Failures are recorded in #mcp_failures, not raised.
|
|
56
|
+
#
|
|
57
|
+
# @return [self]
|
|
58
|
+
def connect_mcp!
|
|
59
|
+
collect_mcp_tools(servers_for(resolve_mcp_hierarchy(@mcp_config)))
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The MCP tools offered by the given servers (default: every
|
|
64
|
+
# registered server). Connects servers that are not connected yet and
|
|
65
|
+
# skips, with a warning, any that cannot be reached.
|
|
66
|
+
#
|
|
67
|
+
# @param servers [Enumerable<RubyLLM::MCP>]
|
|
68
|
+
# @return [Array<RubyLLM::MCP::Tool>]
|
|
69
|
+
def mcp_tools(servers = @mcp_registry)
|
|
70
|
+
collect_mcp_tools(servers.to_a)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Names of servers whose last connection attempt failed. A server is
|
|
74
|
+
# retried on the next run that selects it and leaves this list once it
|
|
75
|
+
# answers.
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<String>]
|
|
78
|
+
def failed_mcp_server_names
|
|
79
|
+
@mcp_failures.keys
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The last connection error per failed server name.
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash{String => Exception}]
|
|
85
|
+
def mcp_failures
|
|
86
|
+
@mcp_failures.dup
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The server that owns +tool+, or nil for a local tool.
|
|
90
|
+
#
|
|
91
|
+
# @param tool [RubyLLM::Tool]
|
|
92
|
+
# @return [RubyLLM::MCP, nil]
|
|
93
|
+
def mcp_server_for(tool)
|
|
94
|
+
@mcp_tool_owners[tool]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Close every MCP server (stopping stdio processes) and the bus channel.
|
|
98
|
+
# Servers stay registered and reconnect on their next use.
|
|
99
|
+
#
|
|
100
|
+
# @return [self]
|
|
101
|
+
def disconnect
|
|
102
|
+
@mcp_registry.each(&:close)
|
|
103
|
+
teardown_bus_channel if @bus
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
11
107
|
private
|
|
12
108
|
|
|
13
109
|
# Resolve MCP hierarchy: runtime -> robot build -> network -> config
|
|
@@ -32,121 +128,79 @@ module RobotLab
|
|
|
32
128
|
network&.network
|
|
33
129
|
end
|
|
34
130
|
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
end
|
|
131
|
+
# The registered server instances a run's resolved +mcp+ value names.
|
|
132
|
+
# Specs not registered yet are registered here. With +mcp_discovery+,
|
|
133
|
+
# the candidates are narrowed to those relevant to +message+ first.
|
|
134
|
+
#
|
|
135
|
+
# @param resolved [Array<Hash, RubyLLM::MCP, Class>] output of resolve_mcp_hierarchy
|
|
136
|
+
# @param message [String, RubyLLM::MCP::Prompt, nil] this run's request
|
|
137
|
+
# @return [Array<RubyLLM::MCP>]
|
|
138
|
+
def servers_for(resolved, message: nil)
|
|
139
|
+
specs = Array(resolved)
|
|
140
|
+
specs = MCP::ServerDiscovery.select(discovery_query(message), from: specs) if @mcp_discovery && message
|
|
141
|
+
specs.map { |spec| @mcp_registry.add(spec) }
|
|
142
|
+
end
|
|
48
143
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
144
|
+
# The text ServerDiscovery scores servers against.
|
|
145
|
+
def discovery_query(message)
|
|
146
|
+
return message.to_s unless message.is_a?(RubyLLM::MCP::Prompt)
|
|
52
147
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
end
|
|
148
|
+
message.messages.map { |prompt_message| prompt_message.content.to_s }.join(' ')
|
|
149
|
+
end
|
|
56
150
|
|
|
57
|
-
|
|
151
|
+
# The tools of +servers+, connecting each as needed. A server that
|
|
152
|
+
# cannot be reached is recorded in @mcp_failures, warned about, and
|
|
153
|
+
# contributes no tools; the run goes on with the rest.
|
|
154
|
+
#
|
|
155
|
+
# @param servers [Array<RubyLLM::MCP>]
|
|
156
|
+
# @return [Array<RubyLLM::MCP::Tool>]
|
|
157
|
+
def collect_mcp_tools(servers)
|
|
158
|
+
servers.flat_map { |server| tools_from(server) }
|
|
58
159
|
end
|
|
59
160
|
|
|
60
|
-
#
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
161
|
+
# One server's tools, or [] (recorded and warned) when it cannot be reached.
|
|
162
|
+
def tools_from(server)
|
|
163
|
+
tools = server.tools
|
|
164
|
+
@mcp_failures.delete(server.name)
|
|
165
|
+
tools.each { |tool| @mcp_tool_owners[tool] = server }
|
|
166
|
+
tools
|
|
167
|
+
rescue *MCP::CONNECT_ERRORS => e
|
|
168
|
+
record_mcp_failure(server, e)
|
|
169
|
+
[]
|
|
170
|
+
end
|
|
64
171
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@mcp_clients[server_name] = client
|
|
68
|
-
discover_mcp_tools(client, server_name)
|
|
69
|
-
else
|
|
70
|
-
server_name = extract_server_name(server_config)
|
|
71
|
-
@failed_mcp_configs[server_name] = server_config
|
|
72
|
-
RobotLab.config.logger.warn(
|
|
73
|
-
"Robot '#{@name}' failed to connect to MCP server: #{server_name}"
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
rescue StandardError => e
|
|
77
|
-
server_name = extract_server_name(server_config)
|
|
78
|
-
@failed_mcp_configs[server_name] = server_config
|
|
172
|
+
def record_mcp_failure(server, error)
|
|
173
|
+
@mcp_failures[server.name] = error
|
|
79
174
|
RobotLab.config.logger.warn(
|
|
80
|
-
"Robot '#{@name}'
|
|
175
|
+
"Robot '#{@name}' could not reach MCP server '#{server.name}': #{error.class}: #{error.message}"
|
|
81
176
|
)
|
|
82
177
|
end
|
|
83
178
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
# Only retry servers that are still needed and still failed
|
|
90
|
-
to_retry = @failed_mcp_configs.slice(*needed_servers)
|
|
91
|
-
return if to_retry.empty?
|
|
92
|
-
|
|
93
|
-
logger = RobotLab.config.logger
|
|
94
|
-
to_retry.each do |name, server_config|
|
|
95
|
-
logger.info(
|
|
96
|
-
"Robot '#{@name}' retrying MCP server: #{name}"
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
client = MCP::Client.new(server_config)
|
|
100
|
-
client.connect
|
|
101
|
-
|
|
102
|
-
if client.connected?
|
|
103
|
-
@mcp_clients[name] = client
|
|
104
|
-
@failed_mcp_configs.delete(name)
|
|
105
|
-
discover_mcp_tools(client, name)
|
|
106
|
-
logger.info(
|
|
107
|
-
"Robot '#{@name}' successfully connected to MCP server '#{name}' on retry"
|
|
108
|
-
)
|
|
109
|
-
end
|
|
110
|
-
rescue StandardError => e
|
|
111
|
-
logger.warn(
|
|
112
|
-
"Robot '#{@name}' retry failed for MCP server '#{name}': #{e.message}"
|
|
113
|
-
)
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# :reek:FeatureEnvy -- adapting each MCP tool definition into a local Tool is exactly this method's purpose.
|
|
118
|
-
# :reek:NestedIterators -- the inner block is the tool's execution closure, not an iteration.
|
|
119
|
-
def discover_mcp_tools(client, server_name)
|
|
120
|
-
tools = client.list_tools
|
|
121
|
-
|
|
122
|
-
tools.each do |tool_def|
|
|
123
|
-
tool_name = tool_def[:name]
|
|
124
|
-
mcp_client = client
|
|
179
|
+
# Whether +tool+ is an MCP tool its server declared deferred (offered to
|
|
180
|
+
# the provider's tool search instead of the model's context).
|
|
181
|
+
def deferred_mcp_tool?(tool)
|
|
182
|
+
server = mcp_server_for(tool)
|
|
183
|
+
return false unless server
|
|
125
184
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
description: tool_def[:description],
|
|
129
|
-
parameters: tool_def[:inputSchema],
|
|
130
|
-
mcp: server_name
|
|
131
|
-
) { |args| mcp_client.call_tool(tool_name, args) }
|
|
132
|
-
|
|
133
|
-
@mcp_tools << tool
|
|
134
|
-
end
|
|
185
|
+
server.defers?(tool)
|
|
186
|
+
end
|
|
135
187
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
)
|
|
188
|
+
# Whether the chat currently holds MCP tools from an earlier run.
|
|
189
|
+
def mcp_tools_attached?
|
|
190
|
+
@chat.tools.each_value.any?(RubyLLM::MCP::Tool)
|
|
139
191
|
end
|
|
140
192
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
193
|
+
# Two tools with one name cannot both be offered; ruby_llm raises when
|
|
194
|
+
# it builds the tool list, so catch it here with the fix spelled out.
|
|
195
|
+
#
|
|
196
|
+
# @param tools [Array<RubyLLM::Tool>]
|
|
197
|
+
# @raise [MCPError]
|
|
198
|
+
def check_unique_tool_names!(tools)
|
|
199
|
+
duplicates = tools.group_by { |tool| tool.name.to_s }.select { |_, same| same.size > 1 }.keys
|
|
200
|
+
return if duplicates.empty?
|
|
201
|
+
|
|
202
|
+
raise MCPError, "Robot '#{@name}' has more than one tool named #{duplicates.join(', ')}. " \
|
|
203
|
+
'Give one of the MCP servers a prefix:, or rename the local tool.'
|
|
150
204
|
end
|
|
151
205
|
end
|
|
152
206
|
end
|