ruby_llm-code_mode 0.1.2 → 0.3.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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +2 -2
  3. data/README.md +57 -8
  4. data/lib/ruby_llm/code_mode.rb +147 -25
  5. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99df9f45b17d0bfb00c9251a705350b87e17bc0d35388284b7d9968afe0945e9
4
- data.tar.gz: bad8594e60eff854ad3d891a12714e1cc879f7015ea57077b28afc9920239715
3
+ metadata.gz: 65329e122014fe96a542cab62f9eb0bb002f3c1ac9f53fae0f5d7b384183433e
4
+ data.tar.gz: c9f4272b1b65da90128fb4a62d211a47606814091ee07095fe55535126aea154
5
5
  SHA512:
6
- metadata.gz: 3b32708303afcbe27574ee5bf9504168e0a93021cf06f62ce0065bd44ee20a06199c21825791946bd27c394279f9bcf9e20f53f62367750942e760b14ddace7f
7
- data.tar.gz: d5efdf5b03124ba97ff9ba07f0f1db87c7b5aeb2de3edd99619e294207ded741ed562635db4a2224f39acb1cd601d8cdbcd43751d7a4f6210a96cb4784dbd95e
6
+ metadata.gz: 7f31a40ab23606bfd6f2c5799ff90c780c514eac95bd3f63d4e4ef933c97adf38ae7d61ca65fe4dc53ba472f8ed38a8a64ae3fcf286656c7694ac2c8c9842817
7
+ data.tar.gz: 1316c738fba0e23e1dc0db07ea63d926cb535bd082cf649a0511c68639e86e777e9077cbe7f1a9973b05e110f023ba9d4d21df3638602956525ca33a2d8e3602
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Juneira
3
+ Copyright (c) 2026 Marcelo Junior
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
21
+ SOFTWARE.
data/README.md CHANGED
@@ -50,7 +50,7 @@ a fixed description built from the declared mounts:
50
50
  - a **Read-only folders** section listing each `mount` as `` `/guest/path` — description``;
51
51
  - a **Read-write folders** section listing each `mount_rw`;
52
52
  - a **Host tools** section listing every bound `RubyLLM::Tool` with its
53
- parameters, when any are bound with `tool`.
53
+ parameters, when any are bound with `tools` or `mcps`.
54
54
 
55
55
  The single parameter is `code` — a complete, self-contained Ruby script.
56
56
 
@@ -70,8 +70,10 @@ A JSON object sent back to the model:
70
70
  ```ruby
71
71
  mount source: host_path, dest: guest_path, description: "..." # read-only
72
72
  mount_rw source: host_path, dest: guest_path, description: "..." # read-write
73
- tool SomeRubyLLMTool # host tool
74
- tool "name" => SomeRubyLLMTool # explicit name
73
+ tools SomeRubyLLMTool # host tool
74
+ tools SomeTool, OtherTool # several (or arrays)
75
+ tools "name" => SomeRubyLLMTool # explicit name(s)
76
+ mcps SomeMcpServer # MCP server(s) (instance or class)
75
77
  ```
76
78
 
77
79
  - `source:` — folder on the host machine, relative to the process working
@@ -79,9 +81,15 @@ tool "name" => SomeRubyLLMTool # explicit nam
79
81
  - `dest:` — absolute, normalized path inside the sandbox (may not overlap
80
82
  the reserved `/work`, `/usr` or `/src` trees, and must be unique).
81
83
  - `description:` — shown to the model in the tool description.
82
- - `tool` — a `RubyLLM::Tool` class or instance; the name inside the sandbox
83
- is derived from the class-name leaf (`MyApp::Tools::Weather` → `weather`),
84
- or the explicit one from the one-pair form.
84
+ - `tools` — one or more `RubyLLM::Tool` classes/instances (or arrays of
85
+ them); the name inside the sandbox is derived from the class-name leaf
86
+ (`MyApp::Tools::Weather` → `weather`), or the explicit one from the
87
+ `"name" => tool` pairs. Called without arguments, it returns the bound
88
+ tools.
89
+ - `mcps` — one or more `RubyLLM::MCP` instances or classes
90
+ (auto-instantiated, with the server's declared inputs as keywords); all of
91
+ each server's tools are bound under their own names. Called without
92
+ arguments, it returns the connected servers.
85
93
 
86
94
  Invalid declarations raise at class-definition time, so a misconfigured tool
87
95
  never reaches a live chat. Subclasses inherit their parent's mounts and tools.
@@ -103,8 +111,8 @@ end
103
111
 
104
112
  class Assistant < RubyLLM::CodeMode
105
113
  mount source: "data", dest: "/data", description: "Project data"
106
- tool Weather # binds as `weather`
107
- tool "forecast" => Weather # or bind it under an explicit name
114
+ tools Weather # binds as `weather`
115
+ tools "forecast" => Weather # or bind it under an explicit name
108
116
  end
109
117
  ```
110
118
 
@@ -130,6 +138,47 @@ report = SB.call("weather", city: "Porto Alegre")
130
138
  tools can be bound to one class.
131
139
  - A `RubyLLM::CodeMode` cannot be bound inside another `CodeMode`.
132
140
 
141
+ ### MCP servers
142
+
143
+ The `mcps` DSL connects [RubyLLM::MCP](https://rubyllm.com/next/mcp/) servers
144
+ and binds all their tools in one call, letting the sandboxed code reach the
145
+ server through the host RPC channel:
146
+
147
+ ```ruby
148
+ docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
149
+
150
+ class DocsResearch < RubyLLM::CodeMode
151
+ mcps docs # instance
152
+ # mcps MicrosoftDocs # or the class (instantiated with .new)
153
+ # mcps Linear, user: current_user # class + the server's declared inputs
154
+ end
155
+ ```
156
+
157
+ Each server tool keeps its server name and its JSON schema is rendered into
158
+ the tool description, so inside the sandbox the model writes:
159
+
160
+ ```ruby
161
+ result = SB.call("microsoft_docs_search", query: "Azure Blob Storage")
162
+ result[:text] # => the tool's text output
163
+ result[:structured] # => parsed structured content, or nil
164
+ result[:error] # => true when the tool reported a failure
165
+ ```
166
+
167
+ - A tool whose name is already bound is re-bound as `<server>_tool`
168
+ (`search` on server `github` → `github_search`); a name that still collides
169
+ after prefixing raises at definition time.
170
+ - Servers that send `instructions` have them listed in the description under
171
+ a "## Server notes" section.
172
+ - Everything fails fast: an argument that is not a `RubyLLM::MCP`, a server
173
+ that cannot be reached, or a stuck name collision roll the whole call back
174
+ and raise at definition time.
175
+ - The class form binds the tools of one instance built at definition time —
176
+ for servers that act per-user, pass a ready instance instead
177
+ (`mcps Linear.new(user: current_user)`) where the user is available.
178
+
179
+ The sandbox itself never sees the network — every `SB.call` performs the MCP
180
+ request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
181
+
133
182
  ### Sandbox limits
134
183
 
135
184
  Defaults: `timeout_ms: 30_000` (wall clock), `fuel_ms: 10_000` (CPU budget).
@@ -5,14 +5,14 @@ require "security_box"
5
5
 
6
6
  module RubyLLM
7
7
  class CodeMode < Tool
8
- VERSION = "0.1.2"
8
+ VERSION = "0.3.0"
9
9
 
10
10
  DEFAULT_TIMEOUT_MS = 30_000
11
11
  DEFAULT_FUEL_MS = 10_000
12
12
  MAX_TOOLS = SecurityBox::Rpcs::MAX_RPCS
13
13
 
14
- TOOL_USAGE = "tool expects a RubyLLM::Tool class or instance, " \
15
- 'or exactly one "name" => tool pair'
14
+ TOOL_USAGE = "tools expects RubyLLM::Tool classes or instances, " \
15
+ 'or "name" => tool pairs'
16
16
 
17
17
  FIXED_DESCRIPTION = <<~DESC.freeze
18
18
  Executes Ruby code inside a secure sandbox and returns what it produced.
@@ -51,6 +51,8 @@ module RubyLLM
51
51
 
52
52
  ToolEntry = Struct.new(:name, :tool, keyword_init: true)
53
53
 
54
+ McpEntry = Struct.new(:name, :mcp, :instructions, keyword_init: true)
55
+
54
56
  class << self
55
57
  def tool_name
56
58
  leaf = name.to_s.split('::').last
@@ -67,32 +69,83 @@ module RubyLLM
67
69
  add_mount(source, dest, :read_write, description)
68
70
  end
69
71
 
70
- def tool(mapping = nil, **kwargs)
71
- if !kwargs.empty?
72
- raise ArgumentError, TOOL_USAGE unless kwargs.size == 1 && mapping.nil?
73
-
74
- name, bound = kwargs.first
75
- mapping = { name.to_s => bound }
72
+ # Binds RubyLLM::Tool classes/instances as host tools callable from
73
+ # inside the sandbox via SB.call:
74
+ #
75
+ # tools Weather # class (instantiated with .new)
76
+ # tools Weather, Echo # several at once
77
+ # tools [Weather, Echo] # or arrays
78
+ # tools "forecast" => Weather # explicit sandbox name
79
+ # tools forecast: Weather # same, as kwargs
80
+ #
81
+ # Without arguments it returns the hash of bound tools. The name is
82
+ # derived from the class-name leaf (`MyApp::Tools::Weather` binds as
83
+ # `weather`). Fails fast: every item must be a RubyLLM::Tool (class or
84
+ # instance), names must be unique and non-empty, and if anything goes
85
+ # wrong nothing is bound.
86
+ def tools(*args, **kwargs)
87
+ return @tools ||= {} if args.empty? && kwargs.empty?
88
+
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
76
102
  end
103
+ tools
104
+ end
77
105
 
78
- if mapping.is_a?(Hash)
79
- raise ArgumentError, TOOL_USAGE unless mapping.size == 1
80
-
81
- name, bound = mapping.first
82
- add_tool(bound, name.to_s)
83
- else
84
- add_tool(mapping, nil)
106
+ # Connects MCP server(s) and binds all their tools into the sandbox:
107
+ #
108
+ # docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
109
+ #
110
+ # class DocsResearch < RubyLLM::CodeMode
111
+ # mcps docs # instance
112
+ # mcps MicrosoftDocs # or the class (instantiated with .new)
113
+ # mcps Linear, user: current_user # class + declared inputs
114
+ # mcps Docs, Github # several servers / arrays at once
115
+ # end
116
+ #
117
+ # Without arguments it returns the array of connected servers. Server
118
+ # tools keep their names (`microsoft_docs_search`, ...); a tool whose
119
+ # name is already bound is re-bound as "<server>_tool". The servers'
120
+ # instructions (when the server sends them) are snapshotted at
121
+ # definition and listed in the description under "## Server notes".
122
+ # Fails fast: every argument must be a RubyLLM::MCP (class or
123
+ # instance), and if anything goes wrong — a server that cannot be
124
+ # reached, a name that stays colliding after prefixing — nothing is
125
+ # bound.
126
+ def mcps(*servers, **inputs)
127
+ return @mcps ||= [] if servers.empty? && inputs.empty?
128
+
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"
85
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
86
143
  end
87
144
 
88
145
  def mounts
89
146
  @mounts ||= []
90
147
  end
91
148
 
92
- def tools
93
- @tools ||= {}
94
- end
95
-
96
149
  def configuration
97
150
  @configuration ||= SecurityBox::Configuration.build(
98
151
  timeout_ms: DEFAULT_TIMEOUT_MS,
@@ -108,7 +161,8 @@ module RubyLLM
108
161
  FIXED_DESCRIPTION.rstrip,
109
162
  folder_section("## Read-only folders (readable, never writable)", read_only),
110
163
  folder_section("## Read-write folders (readable and writable)", read_write),
111
- tools_section
164
+ tools_section,
165
+ server_notes_section
112
166
  ].compact.join("\n\n")
113
167
  end
114
168
 
@@ -128,6 +182,7 @@ module RubyLLM
128
182
  super
129
183
  subclass.instance_variable_set(:@mounts, mounts.dup)
130
184
  subclass.instance_variable_set(:@tools, tools.dup)
185
+ subclass.instance_variable_set(:@mcps, mcps.dup)
131
186
  subclass.instance_variable_set(:@configuration, nil)
132
187
  end
133
188
 
@@ -153,7 +208,7 @@ module RubyLLM
153
208
  raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
154
209
  end
155
210
 
156
- name = (name || leaf_tool_name(instance.class)).to_s
211
+ name = (name || tool_binding_name(instance)).to_s
157
212
  raise ArgumentError, "tool name must be a non-empty String" if name.empty?
158
213
  raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
159
214
  if tools.size >= MAX_TOOLS
@@ -177,8 +232,8 @@ module RubyLLM
177
232
  end
178
233
  end
179
234
 
180
- # Leaf-only name derivation, like .tool_name — the guest has no use for
181
- # the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
235
+ # Leaf-only name derivation, like .tool_name — the guest has no use
236
+ # for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
182
237
  def leaf_tool_name(klass)
183
238
  leaf = klass.name.to_s.split('::').last
184
239
  return "" unless leaf
@@ -186,6 +241,14 @@ module RubyLLM
186
241
  RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
187
242
  end
188
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
+
189
252
  def build_rpcs
190
253
  tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
191
254
  end
@@ -196,7 +259,54 @@ module RubyLLM
196
259
  "tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
197
260
  end
198
261
 
199
- entry.tool.call(**args)
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)
200
310
  end
201
311
 
202
312
  def folder_section(header, entries)
@@ -220,6 +330,18 @@ module RubyLLM
220
330
  ].join("\n")
221
331
  end
222
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
+
223
345
  def tool_lines(entry)
224
346
  line = "- `#{entry.name}`"
225
347
  description = entry.tool.description.to_s
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.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juneira