ruby_llm-code_mode 0.3.0 → 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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +53 -3
  3. data/lib/ruby_llm/code_mode.rb +281 -184
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65329e122014fe96a542cab62f9eb0bb002f3c1ac9f53fae0f5d7b384183433e
4
- data.tar.gz: c9f4272b1b65da90128fb4a62d211a47606814091ee07095fe55535126aea154
3
+ metadata.gz: fb23ad192879e9ce6bcac0204c62c44ad068943dadd5e46a3a2e82bd177411c7
4
+ data.tar.gz: 39b52ca9cf5f733224a1a8a999ae50cbe091467819d86f63a7c38610be665233
5
5
  SHA512:
6
- metadata.gz: 7f31a40ab23606bfd6f2c5799ff90c780c514eac95bd3f63d4e4ef933c97adf38ae7d61ca65fe4dc53ba472f8ed38a8a64ae3fcf286656c7694ac2c8c9842817
7
- data.tar.gz: 1316c738fba0e23e1dc0db07ea63d926cb535bd082cf649a0511c68639e86e777e9077cbe7f1a9973b05e110f023ba9d4d21df3638602956525ca33a2d8e3602
6
+ metadata.gz: 6e41645fd4404b2a70e3a3606ab6b1aba047e20e1e610278ddaac8ae85b8512d0c0af134a897fface831e7a1547b77610444bbb8dffb6a80b468bf4a79f09af2
7
+ data.tar.gz: 60adf578f5deafbd36822e14179e8bcd74856ba6d4b598910cc2be40520ef577f8fd534ffde0ecad2d5ab5c512ff2ac2b98ae60f8c66b0cc0dfa8253bde5b609
data/README.md CHANGED
@@ -22,21 +22,41 @@ gem "ruby_llm-code_mode"
22
22
 
23
23
  ## Usage
24
24
 
25
- Define a tool class, declare the host folders the sandbox may see, and give it to
26
- your chat:
25
+ Define a tool class, declare the host folders the sandbox may see, bind the
26
+ host tools and MCP servers the code may call, and give it to your chat:
27
27
 
28
28
  ```ruby
29
29
  require "ruby_llm"
30
30
  require "ruby_llm/code_mode"
31
31
 
32
+ # A host tool the sandboxed code can call with SB.call("notes", note: "...").
33
+ class Notes < RubyLLM::Tool
34
+ description "Records a finding in the analysis log"
35
+
36
+ parameter :note, type: "string", description: "The finding to record"
37
+
38
+ def execute(note:)
39
+ # host-side write: log file, HTTP call, database query, ...
40
+ { recorded: note }
41
+ end
42
+ end
43
+
44
+ # An MCP server whose tools become callable from inside the sandbox too.
45
+ docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
46
+
32
47
  class Analytics < RubyLLM::CodeMode
33
48
  mount source: "data/input", dest: "/data", description: "CSV files with the raw data (read only)"
34
49
  mount_rw source: "workspace", dest: "/workspace", description: "Write generated reports and artifacts here"
50
+
51
+ tools Notes # one, several (`tools A, B`) or arrays (`tools [A, B]`)
52
+ mcps docs # one, several (`mcps A, B`) or arrays — classes work too
35
53
  end
36
54
 
37
55
  chat = RubyLLM.chat
38
56
  chat.with_tools(Analytics)
39
- chat.ask "Compute the total sales per month from the CSVs in /data and save the report to /workspace/report.md"
57
+ chat.ask "Compute the total sales per month from the CSVs in /data, save the " \
58
+ "report to /workspace/report.md, record the biggest finding in the " \
59
+ "notes log and check the Azure docs for the storage API you used"
40
60
  ```
41
61
 
42
62
  ### What the model sees
@@ -179,6 +199,36 @@ result[:error] # => true when the tool reported a failure
179
199
  The sandbox itself never sees the network — every `SB.call` performs the MCP
180
200
  request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
181
201
 
202
+ ### Per-instance tools and servers
203
+
204
+ Class-level `tools`/`mcps` bind once for every instance of the class. When the
205
+ same tool class needs a different toolset per chat — per-user credentials, a
206
+ server list composed at runtime — bind additions on the instance instead:
207
+
208
+ ```ruby
209
+ tool = Analytics.new
210
+ tool.add_tools(UserSearch.new(user: current_user)) # ready instance (classes work too)
211
+ tool.add_mcps(user_linear) # server instance, class or array
212
+
213
+ chat = RubyLLM.chat
214
+ chat.with_tools(tool) # the description already includes the additions
215
+ chat.ask "Search the docs and log the findings"
216
+ tool.execute(code: 'SB.call("user_search", q: "...")')
217
+ ```
218
+
219
+ - `add_tools` accepts the same forms as `tools`: classes, ready instances,
220
+ several at once, arrays and `"name" => tool` pairs. Names may not collide
221
+ with the class-level bindings or each other, and the 64-tool limit counts
222
+ both levels together.
223
+ - `add_mcps` accepts the same forms as `mcps`; the servers' tools bind into
224
+ this instance only — other instances and the class itself are unaffected.
225
+ - Both are all-or-nothing: on any failure nothing is bound.
226
+ - The instance description includes the additions, so register the tool with
227
+ the chat after calling them.
228
+ - The sandbox is configured on the first execution: after that,
229
+ `add_tools`/`add_mcps` raise — create a new instance instead. Instances
230
+ without additions share the class configuration.
231
+
182
232
  ### Sandbox limits
183
233
 
184
234
  Defaults: `timeout_ms: 30_000` (wall clock), `fuel_ms: 10_000` (CPU budget).
@@ -5,7 +5,7 @@ require "security_box"
5
5
 
6
6
  module RubyLLM
7
7
  class CodeMode < Tool
8
- VERSION = "0.3.0"
8
+ VERSION = "0.4.0"
9
9
 
10
10
  DEFAULT_TIMEOUT_MS = 30_000
11
11
  DEFAULT_FUEL_MS = 10_000
@@ -53,6 +53,221 @@ module RubyLLM
53
53
 
54
54
  McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
55
55
 
56
+ # Shared registration and rendering core, parameterized by the target
57
+ # collections: the class-level DSL (tools/mcps) and the instance-level API
58
+ # (add_tools/add_mcps) run the exact same validation and rollback logic,
59
+ # only over different hashes/arrays.
60
+ module Registry
61
+ EMPTY = {}.freeze
62
+
63
+ class << self
64
+ # Binds tools into `target` (a name => ToolEntry hash). Accepts tool
65
+ # classes/instances, arrays of them, and "name" => tool pairs (hash
66
+ # arguments or kwargs). All-or-nothing: on any failure `target` is
67
+ # restored and the error re-raised. `reserved` lists names already
68
+ # taken elsewhere (e.g. the class-level bindings for instance
69
+ # additions); duplicates against it raise.
70
+ def register_tools(target, args, kwargs, reserved: EMPTY)
71
+ snapshot = target.dup
72
+ begin
73
+ args.flatten(1).each do |item|
74
+ if item.is_a?(Hash)
75
+ item.each { |name, bound| add_tool(target, bound, name.to_s, reserved) }
76
+ else
77
+ add_tool(target, item, nil, reserved)
78
+ end
79
+ end
80
+ kwargs.each { |name, bound| add_tool(target, bound, name.to_s, reserved) }
81
+ rescue StandardError
82
+ target.replace(snapshot)
83
+ raise
84
+ end
85
+ target
86
+ end
87
+
88
+ # Connects MCP server(s) and binds all their tools into
89
+ # `target_tools`/`target_mcps`. All-or-nothing across both
90
+ # collections.
91
+ def register_mcps(target_tools, target_mcps, servers, inputs, reserved: EMPTY)
92
+ snapshot_tools = target_tools.dup
93
+ snapshot_mcps = target_mcps.dup
94
+ flat = servers.flatten(1)
95
+ raise ArgumentError, "expected at least one RubyLLM::MCP server" if flat.empty?
96
+ unless inputs.empty? || flat.all?(Class)
97
+ raise ArgumentError, "inputs only apply when passing RubyLLM::MCP classes"
98
+ end
99
+
100
+ normalize_mcp_servers(flat, inputs).each do |server|
101
+ bind_mcp_server(target_tools, target_mcps, server, reserved)
102
+ end
103
+ target_mcps
104
+ rescue StandardError
105
+ target_tools.replace(snapshot_tools)
106
+ target_mcps.replace(snapshot_mcps)
107
+ raise
108
+ end
109
+
110
+ def rpc_handlers(tools)
111
+ tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
112
+ end
113
+
114
+ def tools_section(tools)
115
+ return nil if tools.empty?
116
+
117
+ [
118
+ "## Host tools (call with SB.call)",
119
+ HOST_TOOLS_INTRO.rstrip,
120
+ *tools.values.flat_map { |entry| tool_lines(entry) }
121
+ ].join("\n")
122
+ end
123
+
124
+ def server_notes_section(mcps)
125
+ notes = mcps.filter_map do |entry|
126
+ instructions = entry.instructions.to_s
127
+ next if instructions.empty?
128
+
129
+ "- **#{entry.name}** — #{instructions}"
130
+ end
131
+ return nil if notes.empty?
132
+
133
+ ["## Server notes", *notes].join("\n")
134
+ end
135
+
136
+ private
137
+
138
+ def add_tool(target, bound, name, reserved)
139
+ instance = normalize_bound_tool(bound)
140
+ if instance.is_a?(RubyLLM::CodeMode)
141
+ raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
142
+ end
143
+
144
+ name = (name || tool_binding_name(instance)).to_s
145
+ raise ArgumentError, "tool name must be a non-empty String" if name.empty?
146
+ if target.key?(name) || reserved.key?(name)
147
+ raise ArgumentError, "tool name #{name.inspect} is already bound"
148
+ end
149
+ total = target.size + reserved.size
150
+ if total >= MAX_TOOLS
151
+ raise ArgumentError, "too many tools (#{total + 1}); the limit is #{MAX_TOOLS}"
152
+ end
153
+
154
+ target[name] = ToolEntry.new(name: name, tool: instance)
155
+ end
156
+
157
+ def normalize_bound_tool(bound)
158
+ if bound.is_a?(Class)
159
+ unless bound <= RubyLLM::Tool
160
+ raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
161
+ end
162
+
163
+ bound.new
164
+ elsif bound.is_a?(RubyLLM::Tool)
165
+ bound
166
+ else
167
+ raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
168
+ end
169
+ end
170
+
171
+ # Leaf-only name derivation, like .tool_name — the guest has no use
172
+ # for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
173
+ def leaf_tool_name(klass)
174
+ leaf = klass.name.to_s.split('::').last
175
+ return "" unless leaf
176
+
177
+ RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
178
+ end
179
+
180
+ # Regular tools keep the leaf-only derivation; MCP tools carry their
181
+ # own (server) name, which is what they bind under inside the sandbox.
182
+ def tool_binding_name(instance)
183
+ return instance.name.to_s if instance.respond_to?(:server_name)
184
+
185
+ leaf_tool_name(instance.class)
186
+ end
187
+
188
+ def call_bound_tool(entry, args)
189
+ unless args.is_a?(Hash)
190
+ raise ArgumentError,
191
+ "tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
192
+ end
193
+
194
+ normalize_result(entry.tool.call(**args))
195
+ end
196
+
197
+ # MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
198
+ # round-trips handler returns, so give the guest plain data instead
199
+ # of an inspect string.
200
+ def normalize_result(value)
201
+ return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
202
+
203
+ { text: value.text, structured: value.structured, error: value.error? }
204
+ end
205
+
206
+ def normalize_mcp_servers(servers, inputs)
207
+ unless defined?(RubyLLM::MCP)
208
+ raise ArgumentError,
209
+ "RubyLLM::MCP is not available; mcps needs a ruby_llm version with MCP support"
210
+ end
211
+
212
+ servers.map do |server|
213
+ case server
214
+ when Class
215
+ unless server <= RubyLLM::MCP
216
+ raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
217
+ end
218
+
219
+ server.new(**inputs)
220
+ when RubyLLM::MCP
221
+ server
222
+ else
223
+ raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
224
+ end
225
+ end
226
+ end
227
+
228
+ def bind_mcp_server(target_tools, target_mcps, server, reserved)
229
+ # Fetching .tools connects to the server; snapshot the instructions
230
+ # right after, so description builds never touch the network.
231
+ tools_list = server.tools
232
+ target_mcps << McpEntry.new(
233
+ name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
234
+ )
235
+ tools_list.each { |tool| bind_mcp_tool(target_tools, server, tool, reserved) }
236
+ end
237
+
238
+ def bind_mcp_tool(target_tools, server, tool, reserved)
239
+ name = tool_binding_name(tool)
240
+ if (target_tools.key?(name) || reserved.key?(name)) && !server.name.to_s.empty?
241
+ name = "#{server.name}_#{name}"
242
+ end
243
+ add_tool(target_tools, tool, name, reserved)
244
+ end
245
+
246
+ def tool_lines(entry)
247
+ line = "- `#{entry.name}`"
248
+ description = entry.tool.description.to_s
249
+ line += " — #{description}" unless description.empty?
250
+
251
+ [line, *param_lines(entry.tool).map { |param| " #{param}" }]
252
+ end
253
+
254
+ def param_lines(tool)
255
+ schema = tool.parameters_schema || {}
256
+ properties = schema["properties"] || {}
257
+ required = schema["required"] || []
258
+
259
+ properties.filter_map do |param, spec|
260
+ spec ||= {}
261
+ status = required.include?(param) ? "required" : "optional"
262
+ line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
263
+ description = spec["description"].to_s
264
+ line += " — #{description}" unless description.empty?
265
+ line
266
+ end
267
+ end
268
+ end
269
+ end
270
+
56
271
  class << self
57
272
  def tool_name
58
273
  leaf = name.to_s.split('::').last
@@ -86,21 +301,7 @@ module RubyLLM
86
301
  def tools(*args, **kwargs)
87
302
  return @tools ||= {} if args.empty? && kwargs.empty?
88
303
 
89
- snapshot = @tools.dup
90
- begin
91
- args.flatten(1).each do |item|
92
- if item.is_a?(Hash)
93
- item.each { |name, bound| add_tool(bound, name.to_s) }
94
- else
95
- add_tool(item, nil)
96
- end
97
- end
98
- kwargs.each { |name, bound| add_tool(bound, name.to_s) }
99
- rescue StandardError
100
- instance_variable_set(:@tools, snapshot)
101
- raise
102
- end
103
- tools
304
+ Registry.register_tools(tools, args, kwargs)
104
305
  end
105
306
 
106
307
  # Connects MCP server(s) and binds all their tools into the sandbox:
@@ -126,20 +327,7 @@ module RubyLLM
126
327
  def mcps(*servers, **inputs)
127
328
  return @mcps ||= [] if servers.empty? && inputs.empty?
128
329
 
129
- flat = servers.flatten(1)
130
- raise ArgumentError, "expected at least one RubyLLM::MCP server" if flat.empty?
131
- unless inputs.empty? || flat.all?(Class)
132
- raise ArgumentError, "inputs only apply when passing RubyLLM::MCP classes"
133
- end
134
-
135
- snapshot_tools = tools.dup
136
- snapshot_mcps = mcps.dup
137
- normalize_mcp_servers(flat, inputs).each { |server| bind_mcp_server(server) }
138
- mcps
139
- rescue StandardError
140
- instance_variable_set(:@tools, snapshot_tools)
141
- instance_variable_set(:@mcps, snapshot_mcps)
142
- raise
330
+ Registry.register_mcps(tools, mcps, servers, inputs)
143
331
  end
144
332
 
145
333
  def mounts
@@ -151,18 +339,21 @@ module RubyLLM
151
339
  timeout_ms: DEFAULT_TIMEOUT_MS,
152
340
  fuel_ms: DEFAULT_FUEL_MS,
153
341
  mounts: mounts.map(&:payload),
154
- rpcs: build_rpcs
342
+ rpcs: Registry.rpc_handlers(tools)
155
343
  )
156
344
  end
157
345
 
158
- def build_description
346
+ # Builds the description the model sees. `extra_tools`/`extra_mcps`
347
+ # carry the per-instance additions (see #add_tools/#add_mcps); with no
348
+ # extras the output is identical to the class-level description.
349
+ def build_description(extra_tools: {}, extra_mcps: [])
159
350
  read_only, read_write = mounts.partition { |m| m.mode == :read_only }
160
351
  [
161
352
  FIXED_DESCRIPTION.rstrip,
162
353
  folder_section("## Read-only folders (readable, never writable)", read_only),
163
354
  folder_section("## Read-write folders (readable and writable)", read_write),
164
- tools_section,
165
- server_notes_section
355
+ Registry.tools_section(tools.merge(extra_tools)),
356
+ Registry.server_notes_section(mcps + extra_mcps)
166
357
  ].compact.join("\n\n")
167
358
  end
168
359
 
@@ -202,113 +393,6 @@ module RubyLLM
202
393
  "Invalid mount #{source.inspect} => #{dest.inspect}: #{e.message}"
203
394
  end
204
395
 
205
- def add_tool(bound, name)
206
- instance = normalize_bound_tool(bound)
207
- if instance.is_a?(RubyLLM::CodeMode)
208
- raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
209
- end
210
-
211
- name = (name || tool_binding_name(instance)).to_s
212
- raise ArgumentError, "tool name must be a non-empty String" if name.empty?
213
- raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
214
- if tools.size >= MAX_TOOLS
215
- raise ArgumentError, "too many tools (#{tools.size + 1}); the limit is #{MAX_TOOLS}"
216
- end
217
-
218
- tools[name] = ToolEntry.new(name: name, tool: instance)
219
- end
220
-
221
- def normalize_bound_tool(bound)
222
- if bound.is_a?(Class)
223
- unless bound <= RubyLLM::Tool
224
- raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
225
- end
226
-
227
- bound.new
228
- elsif bound.is_a?(RubyLLM::Tool)
229
- bound
230
- else
231
- raise ArgumentError, "expected a RubyLLM::Tool (class or instance), got #{bound.inspect}"
232
- end
233
- end
234
-
235
- # Leaf-only name derivation, like .tool_name — the guest has no use
236
- # for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
237
- def leaf_tool_name(klass)
238
- leaf = klass.name.to_s.split('::').last
239
- return "" unless leaf
240
-
241
- RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
242
- end
243
-
244
- # Regular tools keep the leaf-only derivation; MCP tools carry their
245
- # own (server) name, which is what they bind under inside the sandbox.
246
- def tool_binding_name(instance)
247
- return instance.name.to_s if instance.respond_to?(:server_name)
248
-
249
- leaf_tool_name(instance.class)
250
- end
251
-
252
- def build_rpcs
253
- tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
254
- end
255
-
256
- def call_bound_tool(entry, args)
257
- unless args.is_a?(Hash)
258
- raise ArgumentError,
259
- "tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
260
- end
261
-
262
- normalize_result(entry.tool.call(**args))
263
- end
264
-
265
- # MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
266
- # round-trips handler returns, so give the guest plain data instead
267
- # of an inspect string.
268
- def normalize_result(value)
269
- return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
270
-
271
- { text: value.text, structured: value.structured, error: value.error? }
272
- end
273
-
274
- def normalize_mcp_servers(servers, inputs)
275
- unless defined?(RubyLLM::MCP)
276
- raise ArgumentError,
277
- "RubyLLM::MCP is not available; mcps needs a ruby_llm version with MCP support"
278
- end
279
-
280
- servers.map do |server|
281
- case server
282
- when Class
283
- unless server <= RubyLLM::MCP
284
- raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
285
- end
286
-
287
- server.new(**inputs)
288
- when RubyLLM::MCP
289
- server
290
- else
291
- raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
292
- end
293
- end
294
- end
295
-
296
- def bind_mcp_server(server)
297
- # Fetching .tools connects to the server; snapshot the instructions
298
- # right after, so description builds never touch the network.
299
- tools_list = server.tools
300
- mcps << McpEntry.new(
301
- name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
302
- )
303
- tools_list.each { |tool| bind_mcp_tool(server, tool) }
304
- end
305
-
306
- def bind_mcp_tool(server, tool)
307
- name = tool_binding_name(tool)
308
- name = "#{server.name}_#{name}" if tools.key?(name) && !server.name.to_s.empty?
309
- add_tool(tool, name)
310
- end
311
-
312
396
  def folder_section(header, entries)
313
397
  return nil if entries.empty?
314
398
 
@@ -319,54 +403,37 @@ module RubyLLM
319
403
  end
320
404
  [header, *lines].join("\n")
321
405
  end
406
+ end
322
407
 
323
- def tools_section
324
- return nil if tools.empty?
325
-
326
- [
327
- "## Host tools (call with SB.call)",
328
- HOST_TOOLS_INTRO.rstrip,
329
- *tools.values.flat_map { |entry| tool_lines(entry) }
330
- ].join("\n")
331
- end
332
-
333
- def server_notes_section
334
- notes = mcps.filter_map do |entry|
335
- instructions = entry.instructions.to_s
336
- next if instructions.empty?
337
-
338
- "- **#{entry.name}** — #{instructions}"
339
- end
340
- return nil if notes.empty?
341
-
342
- ["## Server notes", *notes].join("\n")
343
- end
344
-
345
- def tool_lines(entry)
346
- line = "- `#{entry.name}`"
347
- description = entry.tool.description.to_s
348
- line += " — #{description}" unless description.empty?
408
+ parameter :code, description: "Complete Ruby script to execute in the sandbox"
349
409
 
350
- [line, *param_lines(entry.tool).map { |param| " #{param}" }]
351
- end
410
+ # Binds more host tools onto this specific instance, on top of the
411
+ # class-level ones. Accepts the same forms as the class-level .tools:
412
+ # classes, ready instances, several at once, arrays, and
413
+ # "name" => tool pairs (hash or kwargs). Instances of the same class are
414
+ # unaffected, and the additions show up in this instance's description,
415
+ # so chats built after the call expose them to the model. Fails fast and
416
+ # rolls everything back; once the first execution happened the sandbox is
417
+ # already built and further additions raise.
418
+ def add_tools(*args, **kwargs)
419
+ ensure_extendable!
420
+
421
+ Registry.register_tools(instance_tools, args, kwargs, reserved: self.class.tools)
422
+ end
352
423
 
353
- def param_lines(tool)
354
- schema = tool.parameters_schema || {}
355
- properties = schema["properties"] || {}
356
- required = schema["required"] || []
424
+ # Connects MCP server(s) onto this specific instance, on top of the
425
+ # class-level ones. Same forms and fail-fast semantics as .mcps
426
+ # (instances, classes with their declared inputs, arrays); the servers'
427
+ # tools bind into this instance only. Raises after the first execution.
428
+ def add_mcps(*servers, **inputs)
429
+ ensure_extendable!
357
430
 
358
- properties.filter_map do |param, spec|
359
- spec ||= {}
360
- status = required.include?(param) ? "required" : "optional"
361
- line = "- `#{param}` (#{spec["type"] || "string"}, #{status})"
362
- description = spec["description"].to_s
363
- line += " — #{description}" unless description.empty?
364
- line
365
- end
366
- end
431
+ Registry.register_mcps(instance_tools, instance_mcps, servers, inputs, reserved: self.class.tools)
367
432
  end
368
433
 
369
- parameter :code, description: "Complete Ruby script to execute in the sandbox"
434
+ def description
435
+ self.class.build_description(extra_tools: instance_tools, extra_mcps: instance_mcps)
436
+ end
370
437
 
371
438
  def execute(code:)
372
439
  format_result(sandbox.eval(code))
@@ -376,8 +443,38 @@ module RubyLLM
376
443
 
377
444
  private
378
445
 
446
+ def ensure_extendable!
447
+ return unless instance_variable_defined?(:@sandbox)
448
+
449
+ raise ArgumentError,
450
+ "cannot add tools or MCP servers after the first execution; " \
451
+ "create a new #{self.class.name} instance instead"
452
+ end
453
+
454
+ def instance_tools
455
+ @instance_tools ||= {}
456
+ end
457
+
458
+ def instance_mcps
459
+ @instance_mcps ||= []
460
+ end
461
+
462
+ # Instances without additions share the class configuration; instances
463
+ # with additions build their own, merging the class rpcs with the
464
+ # instance ones (names cannot collide — the registrations validate that).
465
+ def configuration
466
+ return self.class.configuration if instance_tools.empty? && instance_mcps.empty?
467
+
468
+ @configuration ||= SecurityBox::Configuration.build(
469
+ timeout_ms: DEFAULT_TIMEOUT_MS,
470
+ fuel_ms: DEFAULT_FUEL_MS,
471
+ mounts: self.class.mounts.map(&:payload),
472
+ rpcs: self.class.configuration.rpcs.merge(Registry.rpc_handlers(instance_tools))
473
+ )
474
+ end
475
+
379
476
  def sandbox
380
- @sandbox ||= SecurityBox::Sandbox.new(self.class.configuration)
477
+ @sandbox ||= SecurityBox::Sandbox.new(configuration)
381
478
  end
382
479
 
383
480
  def format_result(result)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-code_mode
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juneira