ruby_llm-code_mode 0.1.2 → 0.2.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 +46 -1
  4. data/lib/ruby_llm/code_mode.rb +117 -6
  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: b92ccf0470dc8d3563eafa55662e7aa0db058095d8ac12a0d60a062547474f81
4
+ data.tar.gz: c2955740ee6b04b1a38279b25f63703194df752fa51f3d4f49166bab3f318ac3
5
5
  SHA512:
6
- metadata.gz: 3b32708303afcbe27574ee5bf9504168e0a93021cf06f62ce0065bd44ee20a06199c21825791946bd27c394279f9bcf9e20f53f62367750942e760b14ddace7f
7
- data.tar.gz: d5efdf5b03124ba97ff9ba07f0f1db87c7b5aeb2de3edd99619e294207ded741ed562635db4a2224f39acb1cd601d8cdbcd43751d7a4f6210a96cb4784dbd95e
6
+ metadata.gz: 7fb1029e393ec6c0041d91d5a45fbef2acdc253fe28427986d0832a73eb33b43becfa42d97983212ac7a3312a771e9506765c271bd4bd2ebcf68968b7adf467e
7
+ data.tar.gz: 52e9aa1bf43f9dcedf0a6e50deb9578c86716bdd5ae353da6717a6553b7f5170f3773f5c40a4229779589be9686e0a3274788d3dc658cdb084fcc5ff862c206a
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 `tool` or `mcp`.
54
54
 
55
55
  The single parameter is `code` — a complete, self-contained Ruby script.
56
56
 
@@ -72,6 +72,7 @@ mount source: host_path, dest: guest_path, description: "..." # read-only
72
72
  mount_rw source: host_path, dest: guest_path, description: "..." # read-write
73
73
  tool SomeRubyLLMTool # host tool
74
74
  tool "name" => SomeRubyLLMTool # explicit name
75
+ mcp SomeMcpServer # MCP server (instance or class)
75
76
  ```
76
77
 
77
78
  - `source:` — folder on the host machine, relative to the process working
@@ -82,6 +83,9 @@ tool "name" => SomeRubyLLMTool # explicit nam
82
83
  - `tool` — a `RubyLLM::Tool` class or instance; the name inside the sandbox
83
84
  is derived from the class-name leaf (`MyApp::Tools::Weather` → `weather`),
84
85
  or the explicit one from the one-pair form.
86
+ - `mcp` — a `RubyLLM::MCP` instance or class (auto-instantiated, with the
87
+ server's declared inputs as keywords); all of the server's tools are bound
88
+ under their own names.
85
89
 
86
90
  Invalid declarations raise at class-definition time, so a misconfigured tool
87
91
  never reaches a live chat. Subclasses inherit their parent's mounts and tools.
@@ -130,6 +134,47 @@ report = SB.call("weather", city: "Porto Alegre")
130
134
  tools can be bound to one class.
131
135
  - A `RubyLLM::CodeMode` cannot be bound inside another `CodeMode`.
132
136
 
137
+ ### MCP servers
138
+
139
+ The `mcp` DSL connects [RubyLLM::MCP](https://rubyllm.com/next/mcp/) servers
140
+ and binds all their tools in one call, letting the sandboxed code reach the
141
+ server through the host RPC channel:
142
+
143
+ ```ruby
144
+ docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
145
+
146
+ class DocsResearch < RubyLLM::CodeMode
147
+ mcp docs # instance
148
+ # mcp MicrosoftDocs # or the class (instantiated with .new)
149
+ # mcp Linear, user: current_user # class + the server's declared inputs
150
+ end
151
+ ```
152
+
153
+ Each server tool keeps its server name and its JSON schema is rendered into
154
+ the tool description, so inside the sandbox the model writes:
155
+
156
+ ```ruby
157
+ result = SB.call("microsoft_docs_search", query: "Azure Blob Storage")
158
+ result[:text] # => the tool's text output
159
+ result[:structured] # => parsed structured content, or nil
160
+ result[:error] # => true when the tool reported a failure
161
+ ```
162
+
163
+ - A tool whose name is already bound is re-bound as `<server>_tool`
164
+ (`search` on server `github` → `github_search`); a name that still collides
165
+ after prefixing raises at definition time.
166
+ - Servers that send `instructions` have them listed in the description under
167
+ a "## Server notes" section.
168
+ - Everything fails fast: an argument that is not a `RubyLLM::MCP`, a server
169
+ that cannot be reached, or a stuck name collision roll the whole call back
170
+ and raise at definition time.
171
+ - The class form binds the tools of one instance built at definition time —
172
+ for servers that act per-user, pass a ready instance instead
173
+ (`mcp Linear.new(user: current_user)`) where the user is available.
174
+
175
+ The sandbox itself never sees the network — every `SB.call` performs the MCP
176
+ request on the host. A runnable example lives in `sample/ruby_llm_mcp/`.
177
+
133
178
  ### Sandbox limits
134
179
 
135
180
  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.1.2"
8
+ VERSION = "0.2.0"
9
9
 
10
10
  DEFAULT_TIMEOUT_MS = 30_000
11
11
  DEFAULT_FUEL_MS = 10_000
@@ -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
@@ -85,6 +87,42 @@ module RubyLLM
85
87
  end
86
88
  end
87
89
 
90
+ # Connects MCP server(s) and binds all their tools into the sandbox:
91
+ #
92
+ # docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
93
+ #
94
+ # class DocsResearch < RubyLLM::CodeMode
95
+ # mcp docs # instance
96
+ # mcp MicrosoftDocs # or the class (instantiated with .new)
97
+ # mcp Linear, user: current_user # class + declared inputs
98
+ # mcp Docs, Github # several servers / arrays at once
99
+ # end
100
+ #
101
+ # Server tools keep their names (`microsoft_docs_search`, ...); a tool
102
+ # whose name is already bound is re-bound as "<server>_tool". The
103
+ # servers' instructions (when the server sends them) are snapshotted
104
+ # at definition and listed in the description under "## Server notes".
105
+ # Fails fast: every argument must be a RubyLLM::MCP (class or
106
+ # instance), and if anything goes wrong — a server that cannot be
107
+ # reached, a name that stays colliding after prefixing — nothing is
108
+ # bound.
109
+ def mcp(*servers, **inputs)
110
+ flat = servers.flatten(1)
111
+ raise ArgumentError, "expected at least one RubyLLM::MCP server" if flat.empty?
112
+ unless inputs.empty? || flat.all?(Class)
113
+ raise ArgumentError, "inputs only apply when passing RubyLLM::MCP classes"
114
+ end
115
+
116
+ snapshot_tools = tools.dup
117
+ snapshot_mcps = mcps.dup
118
+ normalize_mcp_servers(flat, inputs).each { |server| bind_mcp_server(server) }
119
+ mcps
120
+ rescue StandardError
121
+ instance_variable_set(:@tools, snapshot_tools)
122
+ instance_variable_set(:@mcps, snapshot_mcps)
123
+ raise
124
+ end
125
+
88
126
  def mounts
89
127
  @mounts ||= []
90
128
  end
@@ -93,6 +131,10 @@ module RubyLLM
93
131
  @tools ||= {}
94
132
  end
95
133
 
134
+ def mcps
135
+ @mcps ||= []
136
+ end
137
+
96
138
  def configuration
97
139
  @configuration ||= SecurityBox::Configuration.build(
98
140
  timeout_ms: DEFAULT_TIMEOUT_MS,
@@ -108,7 +150,8 @@ module RubyLLM
108
150
  FIXED_DESCRIPTION.rstrip,
109
151
  folder_section("## Read-only folders (readable, never writable)", read_only),
110
152
  folder_section("## Read-write folders (readable and writable)", read_write),
111
- tools_section
153
+ tools_section,
154
+ server_notes_section
112
155
  ].compact.join("\n\n")
113
156
  end
114
157
 
@@ -128,6 +171,7 @@ module RubyLLM
128
171
  super
129
172
  subclass.instance_variable_set(:@mounts, mounts.dup)
130
173
  subclass.instance_variable_set(:@tools, tools.dup)
174
+ subclass.instance_variable_set(:@mcps, mcps.dup)
131
175
  subclass.instance_variable_set(:@configuration, nil)
132
176
  end
133
177
 
@@ -153,7 +197,7 @@ module RubyLLM
153
197
  raise ArgumentError, "cannot bind a RubyLLM::CodeMode inside another CodeMode"
154
198
  end
155
199
 
156
- name = (name || leaf_tool_name(instance.class)).to_s
200
+ name = (name || tool_binding_name(instance)).to_s
157
201
  raise ArgumentError, "tool name must be a non-empty String" if name.empty?
158
202
  raise ArgumentError, "tool name #{name.inspect} is already bound" if tools.key?(name)
159
203
  if tools.size >= MAX_TOOLS
@@ -177,8 +221,8 @@ module RubyLLM
177
221
  end
178
222
  end
179
223
 
180
- # Leaf-only name derivation, like .tool_name — the guest has no use for
181
- # the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
224
+ # Leaf-only name derivation, like .tool_name — the guest has no use
225
+ # for the RubyLLM namespace ("MyApp::Tools::Search" binds as "search").
182
226
  def leaf_tool_name(klass)
183
227
  leaf = klass.name.to_s.split('::').last
184
228
  return "" unless leaf
@@ -186,6 +230,14 @@ module RubyLLM
186
230
  RubyLLM::Support::Utils.underscore(leaf).delete_suffix('_tool')
187
231
  end
188
232
 
233
+ # Regular tools keep the leaf-only derivation; MCP tools carry their
234
+ # own (server) name, which is what they bind under inside the sandbox.
235
+ def tool_binding_name(instance)
236
+ return instance.name.to_s if instance.respond_to?(:server_name)
237
+
238
+ leaf_tool_name(instance.class)
239
+ end
240
+
189
241
  def build_rpcs
190
242
  tools.transform_values { |entry| ->(args) { call_bound_tool(entry, args) } }
191
243
  end
@@ -196,7 +248,54 @@ module RubyLLM
196
248
  "tool #{entry.name.inspect} expects a hash of arguments, got #{args.class}"
197
249
  end
198
250
 
199
- entry.tool.call(**args)
251
+ normalize_result(entry.tool.call(**args))
252
+ end
253
+
254
+ # MCP tools return RubyLLM::MCP::Result objects; SecurityBox JSON
255
+ # round-trips handler returns, so give the guest plain data instead
256
+ # of an inspect string.
257
+ def normalize_result(value)
258
+ return value unless defined?(RubyLLM::MCP::Result) && value.is_a?(RubyLLM::MCP::Result)
259
+
260
+ { text: value.text, structured: value.structured, error: value.error? }
261
+ end
262
+
263
+ def normalize_mcp_servers(servers, inputs)
264
+ unless defined?(RubyLLM::MCP)
265
+ raise ArgumentError,
266
+ "RubyLLM::MCP is not available; mcp needs a ruby_llm version with MCP support"
267
+ end
268
+
269
+ servers.map do |server|
270
+ case server
271
+ when Class
272
+ unless server <= RubyLLM::MCP
273
+ raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
274
+ end
275
+
276
+ server.new(**inputs)
277
+ when RubyLLM::MCP
278
+ server
279
+ else
280
+ raise ArgumentError, "expected a RubyLLM::MCP (class or instance), got #{server.inspect}"
281
+ end
282
+ end
283
+ end
284
+
285
+ def bind_mcp_server(server)
286
+ # Fetching .tools connects to the server; snapshot the instructions
287
+ # right after, so description builds never touch the network.
288
+ tools_list = server.tools
289
+ mcps << McpEntry.new(
290
+ name: server.name.to_s, mcp: server, instructions: server.instructions.to_s
291
+ )
292
+ tools_list.each { |tool| bind_mcp_tool(server, tool) }
293
+ end
294
+
295
+ def bind_mcp_tool(server, tool)
296
+ name = tool_binding_name(tool)
297
+ name = "#{server.name}_#{name}" if tools.key?(name) && !server.name.to_s.empty?
298
+ add_tool(tool, name)
200
299
  end
201
300
 
202
301
  def folder_section(header, entries)
@@ -220,6 +319,18 @@ module RubyLLM
220
319
  ].join("\n")
221
320
  end
222
321
 
322
+ def server_notes_section
323
+ notes = mcps.filter_map do |entry|
324
+ instructions = entry.instructions.to_s
325
+ next if instructions.empty?
326
+
327
+ "- **#{entry.name}** — #{instructions}"
328
+ end
329
+ return nil if notes.empty?
330
+
331
+ ["## Server notes", *notes].join("\n")
332
+ end
333
+
223
334
  def tool_lines(entry)
224
335
  line = "- `#{entry.name}`"
225
336
  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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juneira