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.
@@ -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 client lifecycle and hierarchical tool/MCP resolution.
5
+ # MCP servers: the robot's registry, per-run server selection, tool
6
+ # collection, and connection failures.
6
7
  #
7
- # Owns: @mcp_clients, @mcp_tools, @mcp_initialized, @failed_mcp_configs
8
- # Reads: @mcp_config, @tools_config, @name, @chat, @local_tools
9
- # Contract: ivars initialized by initialize_runtime_state; lazy init on first run
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
- # Ensure MCP clients are initialized for the given server configs.
36
- # On subsequent calls, retries any servers that previously failed to connect.
37
- # :reek:TooManyStatements -- first-run init vs retry paths share the needed-server list; linear either way.
38
- def ensure_mcp_clients(mcp_servers)
39
- return if mcp_servers.empty?
40
-
41
- needed_servers = mcp_servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
42
-
43
- if @mcp_initialized
44
- # Already initialized — retry any servers that are needed but not connected
45
- retry_failed_servers(mcp_servers, needed_servers)
46
- return
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
- @mcp_clients = {}
50
- @mcp_tools = []
51
- @failed_mcp_configs = {}
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
- mcp_servers.each do |server_config|
54
- init_mcp_client(server_config)
55
- end
148
+ message.messages.map { |prompt_message| prompt_message.content.to_s }.join(' ')
149
+ end
56
150
 
57
- @mcp_initialized = true
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
- # :reek:TooManyStatements -- connect success/failure bookkeeping plus the rescue path for one server.
61
- def init_mcp_client(server_config)
62
- client = MCP::Client.new(server_config)
63
- client.connect
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
- if client.connected?
66
- server_name = client.server.name
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}' error connecting to MCP server '#{server_name}': #{e.message}"
175
+ "Robot '#{@name}' could not reach MCP server '#{server.name}': #{error.class}: #{error.message}"
81
176
  )
82
177
  end
83
178
 
84
- # Retry connecting to servers that previously failed
85
- # :reek:TooManyStatements -- per-server retry with success bookkeeping and a best-effort rescue.
86
- def retry_failed_servers(_mcp_servers, needed_servers)
87
- return if @failed_mcp_configs.nil? || @failed_mcp_configs.empty?
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
- tool = Tool.create(
127
- name: tool_name,
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
- RobotLab.config.logger.info(
137
- "Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
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
- def extract_server_name(server_config)
142
- case server_config
143
- when Hash
144
- server_config[:name] || server_config['name'] || server_config.to_s
145
- when MCP::Server
146
- server_config.name
147
- else
148
- server_config.to_s
149
- end
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