jrubyagents 0.2.1

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,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class Agent
5
+ attr_reader :name, :description, :model, :tools, :max_steps, :memory, :planning_interval, :step_callbacks, :callbacks
6
+
7
+ def initialize(model:, tools: [], agents: [], name: nil, description: nil,
8
+ max_steps: 10, planning_interval: nil, step_callbacks: [],
9
+ callbacks: [], final_answer_checks: [], prompt_templates: nil,
10
+ instructions: nil, output_type: nil)
11
+ @name = name
12
+ @description = description
13
+ @model = model.is_a?(String) ? Model.for(model) : model
14
+ @tools = build_tools(tools, agents)
15
+ @max_steps = max_steps
16
+ @planning_interval = planning_interval
17
+ @step_callbacks = step_callbacks
18
+ @callbacks = Array(callbacks)
19
+ @final_answer_checks = final_answer_checks
20
+ @prompt_templates = prompt_templates || PromptTemplates.new
21
+ @instructions = instructions
22
+ @output_type = output_type
23
+ @memory = nil
24
+ @interrupt_switch = false
25
+ @step_number = 0
26
+ @final_answer_value = nil
27
+ @tool_map = @tools.each_with_object({}) { |t, h| h[t.class.tool_name] = t }
28
+ end
29
+
30
+ def run(task, reset: true, return_full_result: false, &on_stream)
31
+ @interrupt_switch = false
32
+
33
+ if reset || @memory.nil?
34
+ @memory = Memory.new(system_prompt: system_prompt, task: task)
35
+ else
36
+ memory.add_user_message(task)
37
+ end
38
+
39
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
40
+ notify(:on_run_start, task: task)
41
+
42
+ (1..max_steps).each do |step_number|
43
+ raise InterruptError, "Agent interrupted" if @interrupt_switch
44
+
45
+ notify(:on_step_start, step_number: step_number)
46
+ maybe_plan(step_number)
47
+
48
+ # Call LLM with timing
49
+ llm_duration, response = timed { generate_response(memory.to_messages, &on_stream) }
50
+
51
+ # Parse response
52
+ thought, action = parse_response(response)
53
+ UI.thought(thought) if thought
54
+
55
+ # Check for final answer or execute
56
+ if action
57
+ result = run_action(thought, action, response, llm_duration)
58
+ if result
59
+ error_msg = validate_final_answer(result)
60
+ error_msg ||= validate_output_type(result)
61
+ if error_msg
62
+ memory.add_user_message(error_msg)
63
+ next
64
+ end
65
+ total_timing = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
66
+ built = build_result(result, total_timing, return_full_result)
67
+ notify(:on_run_end, result: built)
68
+ return built
69
+ end
70
+ else
71
+ UI.step_metrics(duration: llm_duration, token_usage: response.token_usage)
72
+ step = memory.add_step(thought: thought || response.content, code: nil, observation: nil,
73
+ duration: llm_duration, token_usage: response.token_usage)
74
+ notify_callbacks(step)
75
+ end
76
+ end
77
+
78
+ # Max steps reached
79
+ UI.run_summary(
80
+ total_steps: memory.action_steps.size,
81
+ total_duration: memory.total_duration,
82
+ total_tokens: memory.total_tokens
83
+ )
84
+ UI.error("Max steps (#{max_steps}) reached without a final answer")
85
+ notify(:on_error, error: MaxStepsError.new("Max steps (#{max_steps}) reached"))
86
+
87
+ total_timing = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
88
+ output = memory.last_step&.observation
89
+ result = RunResult.new(
90
+ output: output,
91
+ state: "max_steps",
92
+ steps: memory.action_steps,
93
+ token_usage: memory.total_tokens,
94
+ timing: total_timing
95
+ )
96
+ notify(:on_run_end, result: return_full_result ? result : output)
97
+ return_full_result ? result : output
98
+ end
99
+
100
+ # --- Step-by-step execution ---
101
+
102
+ def step(task_or_message = nil)
103
+ if @memory.nil?
104
+ raise ArgumentError, "task required on first call" unless task_or_message
105
+ @memory = Memory.new(system_prompt: system_prompt, task: task_or_message)
106
+ @step_number = 0
107
+ @interrupt_switch = false
108
+ @final_answer_value = nil
109
+ elsif task_or_message
110
+ memory.add_user_message(task_or_message)
111
+ end
112
+
113
+ @step_number += 1
114
+ raise MaxStepsError, "Max steps (#{max_steps}) reached" if @step_number > max_steps
115
+ raise InterruptError, "Agent interrupted" if @interrupt_switch
116
+
117
+ maybe_plan(@step_number)
118
+
119
+ llm_duration, response = timed { generate_response(memory.to_messages) }
120
+ thought, action = parse_response(response)
121
+
122
+ if action
123
+ result = run_action(thought, action, response, llm_duration)
124
+ if result
125
+ error_msg = validate_final_answer(result)
126
+ error_msg ||= validate_output_type(result)
127
+ if error_msg
128
+ memory.add_user_message(error_msg)
129
+ else
130
+ @final_answer_value = result
131
+ end
132
+ end
133
+ else
134
+ memory.add_step(thought: thought || response.content,
135
+ duration: llm_duration, token_usage: response.token_usage)
136
+ end
137
+
138
+ memory.last_step
139
+ end
140
+
141
+ def done? = !@final_answer_value.nil?
142
+
143
+ def final_answer_value = @final_answer_value
144
+
145
+ def reset!
146
+ @memory = nil
147
+ @step_number = 0
148
+ @final_answer_value = nil
149
+ @interrupt_switch = false
150
+ end
151
+
152
+ def interrupt
153
+ @interrupt_switch = true
154
+ end
155
+
156
+ private
157
+
158
+ def system_prompt
159
+ raise NotImplementedError
160
+ end
161
+
162
+ def generate_response(messages, &on_stream)
163
+ spin = on_stream ? nil : UI.spinner("Thinking...")
164
+ spin&.start
165
+ response = @model.generate(messages, &on_stream)
166
+ spin&.stop
167
+ response
168
+ end
169
+
170
+ def parse_response(response)
171
+ raise NotImplementedError
172
+ end
173
+
174
+ def execute(action)
175
+ raise NotImplementedError
176
+ end
177
+
178
+ def run_action(thought, action, response, llm_duration)
179
+ raise NotImplementedError
180
+ end
181
+
182
+ def maybe_plan(step_number)
183
+ return unless planning_interval
184
+ if step_number == 1
185
+ run_planning_step(initial: true)
186
+ elsif (step_number % planning_interval) == 1
187
+ run_planning_step(initial: false)
188
+ end
189
+ end
190
+
191
+ def run_planning_step(initial: true)
192
+ spin = UI.spinner("Planning...")
193
+ spin.start
194
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
195
+
196
+ planning_messages = memory.to_messages
197
+ if initial
198
+ plan_prompt = @prompt_templates.planning_initial || Prompt.initial_plan
199
+ planning_messages[0] = { role: "system", content: plan_prompt }
200
+ else
201
+ plan_prompt = @prompt_templates.planning_update || Prompt.update_plan(progress_summary: memory.progress_summary)
202
+ planning_messages[0] = { role: "system", content: plan_prompt }
203
+ end
204
+
205
+ response = @model.generate(planning_messages)
206
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
207
+ spin.stop
208
+
209
+ UI.plan(response.content)
210
+ UI.step_metrics(duration: duration, token_usage: response.token_usage)
211
+ memory.add_plan(plan: response.content, duration: duration, token_usage: response.token_usage)
212
+ end
213
+
214
+ def validate_final_answer(answer)
215
+ @final_answer_checks.each_with_index do |check, i|
216
+ unless check.call(answer, memory)
217
+ return "Final answer rejected by check ##{i + 1}. Please try a different answer."
218
+ end
219
+ end
220
+ nil
221
+ end
222
+
223
+ def validate_output_type(answer)
224
+ return nil unless @output_type
225
+ case @output_type
226
+ when Hash
227
+ require "json-schema"
228
+ errors = JSON::Validator.fully_validate(@output_type, answer)
229
+ errors.empty? ? nil : "Output validation failed: #{errors.join("; ")}"
230
+ when Proc
231
+ @output_type.call(answer) ? nil : "Output validation failed: custom validator returned falsy"
232
+ end
233
+ end
234
+
235
+ def build_tools(tool_classes, agents)
236
+ instances = [FinalAnswerTool.new]
237
+
238
+ tool_classes.each do |klass|
239
+ instances << (klass.is_a?(Tool) ? klass : klass.new)
240
+ end
241
+
242
+ agents.each do |agent|
243
+ instances << ManagedAgentTool.for(agent)
244
+ end
245
+
246
+ instances
247
+ end
248
+
249
+ def build_result(output, timing, return_full_result)
250
+ UI.run_summary(
251
+ total_steps: memory.action_steps.size,
252
+ total_duration: memory.total_duration,
253
+ total_tokens: memory.total_tokens
254
+ )
255
+ UI.final_answer(output)
256
+
257
+ result = RunResult.new(
258
+ output: output,
259
+ state: "success",
260
+ steps: memory.action_steps,
261
+ token_usage: memory.total_tokens,
262
+ timing: timing
263
+ )
264
+ return_full_result ? result : output
265
+ end
266
+
267
+ def notify_callbacks(step)
268
+ @step_callbacks.each { |cb| cb.call(step, agent: self) }
269
+ notify(:on_step_end, step: step)
270
+ end
271
+
272
+ def notify(event, **payload)
273
+ @callbacks.each do |cb|
274
+ cb.send(event, **payload, agent: self) if cb.respond_to?(event)
275
+ end
276
+ end
277
+
278
+ def format_output(result)
279
+ parts = []
280
+ parts << result[:output] unless result[:output].to_s.empty?
281
+ parts << result[:result].inspect unless result[:result].nil?
282
+ parts.join("\n")
283
+ end
284
+
285
+ def timed
286
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
287
+ result = yield
288
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
289
+ [duration, result]
290
+ end
291
+ end
292
+
293
+ class ManagedAgentTool < Tool
294
+ tool_name "call_agent"
295
+ description "Delegates a task to a managed agent"
296
+ input :task, type: :string, description: "Task to delegate"
297
+ output_type :string
298
+
299
+ # Factory that creates an anonymous subclass per agent instance
300
+ def self.for(agent)
301
+ klass = Class.new(self) do
302
+ tool_name(agent.name || "agent")
303
+ description(agent.description || "A managed agent")
304
+ end
305
+ klass.new(agent)
306
+ end
307
+
308
+ def initialize(agent)
309
+ @agent = agent
310
+ end
311
+
312
+ def call(task:, **_kwargs)
313
+ @agent.run(task)
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class Callback
5
+ def on_run_start(task:, agent:) = nil
6
+ def on_step_start(step_number:, agent:) = nil
7
+ def on_step_end(step:, agent:) = nil
8
+ def on_tool_call(tool_name:, arguments:, agent:) = nil
9
+ def on_error(error:, agent:) = nil
10
+ def on_run_end(result:, agent:) = nil
11
+ end
12
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rubyagents
6
+ class CLI
7
+ TOOL_MAP = {
8
+ "web_search" => -> { require_relative "tools/web_search"; WebSearch },
9
+ "visit_webpage" => -> { require_relative "tools/visit_webpage"; VisitWebpage },
10
+ "user_input" => -> { require_relative "tools/user_input"; UserInput },
11
+ "file_read" => -> { require_relative "tools/file_read"; FileRead },
12
+ "file_write" => -> { require_relative "tools/file_write"; FileWrite }
13
+ }.freeze
14
+
15
+ def self.run(argv = ARGV)
16
+ new(argv).run
17
+ end
18
+
19
+ def initialize(argv)
20
+ @argv = argv
21
+ @options = {
22
+ model: "openai/gpt-5.2",
23
+ tools: [],
24
+ mcp: [],
25
+ interactive: false,
26
+ max_steps: 10,
27
+ planning_interval: nil,
28
+ agent_type: "code"
29
+ }
30
+ parse_options!
31
+ end
32
+
33
+ def run
34
+ if @options[:interactive]
35
+ interactive_mode
36
+ elsif @query
37
+ single_query(@query)
38
+ else
39
+ puts @parser.help
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def parse_options!
46
+ @parser = OptionParser.new do |opts|
47
+ opts.banner = "Usage: rubyagents [options] \"query\""
48
+ opts.separator ""
49
+
50
+ opts.on("-m", "--model MODEL", "Model to use (default: openai/gpt-5.2)") do |m|
51
+ @options[:model] = m
52
+ end
53
+
54
+ opts.on("-t", "--tools TOOLS", "Comma-separated tool names (#{TOOL_MAP.keys.join(", ")})") do |t|
55
+ @options[:tools] = t.split(",").map(&:strip)
56
+ end
57
+
58
+ opts.on("-a", "--agent-type TYPE", "Agent type: code or tool_calling (default: code)") do |a|
59
+ @options[:agent_type] = a
60
+ end
61
+
62
+ opts.on("-p", "--plan N", Integer, "Re-plan every N steps") do |n|
63
+ @options[:planning_interval] = n
64
+ end
65
+
66
+ opts.on("-i", "--interactive", "Interactive mode") do
67
+ @options[:interactive] = true
68
+ end
69
+
70
+ opts.on("--mcp COMMAND", "MCP server command (repeatable)") do |cmd|
71
+ @options[:mcp] << cmd
72
+ end
73
+
74
+ opts.on("-s", "--max-steps N", Integer, "Max agent steps (default: 10)") do |n|
75
+ @options[:max_steps] = n
76
+ end
77
+
78
+ opts.on("-v", "--version", "Show version") do
79
+ puts "rubyagents #{VERSION}"
80
+ exit
81
+ end
82
+
83
+ opts.on("-h", "--help", "Show help") do
84
+ puts opts
85
+ exit
86
+ end
87
+ end
88
+
89
+ @parser.parse!(@argv)
90
+ @query = @argv.join(" ") unless @argv.empty?
91
+ end
92
+
93
+ def build_agent
94
+ tools = @options[:tools].filter_map do |name|
95
+ loader = TOOL_MAP[name]
96
+ if loader
97
+ loader.call
98
+ else
99
+ $stderr.puts "Unknown tool: #{name}. Available: #{TOOL_MAP.keys.join(", ")}"
100
+ nil
101
+ end
102
+ end
103
+
104
+ @options[:mcp].each do |cmd|
105
+ mcp_tools = Rubyagents.tools_from_mcp(command: cmd)
106
+ tools.concat(mcp_tools)
107
+ end
108
+
109
+ agent_class = @options[:agent_type] == "tool_calling" ? ToolCallingAgent : CodeAgent
110
+
111
+ agent_class.new(
112
+ model: @options[:model],
113
+ tools: tools,
114
+ max_steps: @options[:max_steps],
115
+ planning_interval: @options[:planning_interval]
116
+ )
117
+ end
118
+
119
+ def single_query(query)
120
+ UI.welcome
121
+ agent = build_agent
122
+ agent.run(query)
123
+ end
124
+
125
+ def interactive_mode
126
+ UI.welcome
127
+ puts "Type your queries (Ctrl+C to exit)\n\n"
128
+
129
+ agent = build_agent
130
+ first = true
131
+
132
+ loop do
133
+ prompt = RUBY_ENGINE == "jruby" ? ">> " : Lipgloss::Style.new.bold(true).foreground("#7B61FF").render(">> ")
134
+ print prompt
135
+ query = $stdin.gets&.strip
136
+ break if query.nil? || query.empty?
137
+
138
+ agent.run(query, reset: first)
139
+ first = false
140
+ puts
141
+ end
142
+ rescue Interrupt
143
+ puts "\nGoodbye!"
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class CodeAgent < Agent
5
+ CODE_BLOCK_RE = /```ruby\s*\n(.*?)```/m
6
+
7
+ def initialize(model:, tools: [], agents: [], name: nil, description: nil,
8
+ max_steps: 10, timeout: 30, planning_interval: nil, step_callbacks: [],
9
+ callbacks: [], final_answer_checks: [], prompt_templates: nil,
10
+ instructions: nil, output_type: nil)
11
+ require_relative "tools/list_gems"
12
+ tools = [ListGems] + tools unless tools.any? { |t| t == ListGems || (t.is_a?(Tool) && t.is_a?(ListGems)) }
13
+ super(model: model, tools: tools, agents: agents, name: name, description: description,
14
+ max_steps: max_steps, planning_interval: planning_interval, step_callbacks: step_callbacks,
15
+ callbacks: callbacks, final_answer_checks: final_answer_checks,
16
+ prompt_templates: prompt_templates, instructions: instructions, output_type: output_type)
17
+ @timeout = timeout
18
+ end
19
+
20
+ private
21
+
22
+ def system_prompt
23
+ prompt = if @prompt_templates.system_prompt
24
+ tool_descriptions = tools.map { |t| t.class.to_prompt }.join("\n\n")
25
+ @prompt_templates.system_prompt.gsub("{{tool_descriptions}}", tool_descriptions)
26
+ else
27
+ Prompt.code_agent_system(tools: tools)
28
+ end
29
+ prompt = "#{prompt}\n\n#{@instructions}" if @instructions
30
+ prompt
31
+ end
32
+
33
+ def parse_response(response)
34
+ content = response.content
35
+
36
+ thought = nil
37
+ if content =~ /Thought:\s*(.*?)(?=Code:|```ruby)/m
38
+ thought = $1.strip
39
+ elsif content =~ /Thought:\s*(.*)/m
40
+ thought = $1.strip
41
+ end
42
+
43
+ code = nil
44
+ if content =~ CODE_BLOCK_RE
45
+ code = $1.strip
46
+ end
47
+
48
+ [thought, code]
49
+ end
50
+
51
+ def run_action(thought, code, response, llm_duration)
52
+ UI.code(code)
53
+ exec_duration, result = timed { execute(code) }
54
+ total_duration = llm_duration + exec_duration
55
+
56
+ if result[:error]
57
+ UI.error(result[:error])
58
+ UI.step_metrics(duration: total_duration, token_usage: response.token_usage)
59
+ step = memory.add_step(thought: thought, code: code, error: result[:error],
60
+ duration: total_duration, token_usage: response.token_usage)
61
+ notify_callbacks(step)
62
+ nil
63
+ else
64
+ output = format_output(result)
65
+ UI.observation(output)
66
+ UI.step_metrics(duration: total_duration, token_usage: response.token_usage)
67
+
68
+ step = memory.add_step(thought: thought, code: code, observation: output,
69
+ duration: total_duration, token_usage: response.token_usage)
70
+ notify_callbacks(step)
71
+
72
+ result[:is_final_answer] ? result[:result] : nil
73
+ end
74
+ end
75
+
76
+ def execute(code)
77
+ spin = UI.spinner("Executing code...")
78
+ spin.start
79
+ sandbox = Sandbox.new(tools: tools, timeout: @timeout)
80
+ result = sandbox.execute(code)
81
+ spin.stop
82
+ result
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class Error < StandardError; end
5
+ class AgentError < Error; end
6
+ class ParsingError < AgentError; end
7
+ class ExecutionError < AgentError; end
8
+ class GenerationError < AgentError; end
9
+ class MaxStepsError < AgentError; end
10
+ class InterruptError < AgentError; end
11
+
12
+ # Inherits Exception (not StandardError) so agent-generated `rescue => e` won't catch it
13
+ class FinalAnswerException < Exception # rubocop:disable Lint/InheritException
14
+ attr_reader :value
15
+
16
+ def initialize(value)
17
+ @value = value
18
+ super("Final answer reached")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+
6
+ module Rubyagents
7
+ module MCP
8
+ class StdioTransport
9
+ def initialize(command:)
10
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*Array(command))
11
+ @request_id = 0
12
+ handshake!
13
+ end
14
+
15
+ def send_request(request:)
16
+ @request_id += 1
17
+ payload = { jsonrpc: "2.0", id: @request_id }.merge(request)
18
+ write_message(payload)
19
+ read_response(@request_id)
20
+ end
21
+
22
+ def close
23
+ @stdin.close unless @stdin.closed?
24
+ @stdout.close unless @stdout.closed?
25
+ @stderr.close unless @stderr.closed?
26
+ @wait_thread.join
27
+ end
28
+
29
+ private
30
+
31
+ def handshake!
32
+ # Send initialize
33
+ @request_id += 1
34
+ init_request = {
35
+ jsonrpc: "2.0",
36
+ id: @request_id,
37
+ method: "initialize",
38
+ params: {
39
+ protocolVersion: "2024-11-05",
40
+ capabilities: {},
41
+ clientInfo: { name: "rubyagents", version: Rubyagents::VERSION }
42
+ }
43
+ }
44
+ write_message(init_request)
45
+ read_response(@request_id)
46
+
47
+ # Send initialized notification (no id)
48
+ write_message({ jsonrpc: "2.0", method: "notifications/initialized" })
49
+ end
50
+
51
+ def write_message(hash)
52
+ line = JSON.generate(hash)
53
+ @stdin.puts(line)
54
+ @stdin.flush
55
+ end
56
+
57
+ def read_response(expected_id)
58
+ loop do
59
+ line = @stdout.gets
60
+ raise "MCP server closed connection" unless line
61
+ msg = JSON.parse(line.strip)
62
+ # Skip notifications (no id)
63
+ next unless msg.key?("id")
64
+ return msg if msg["id"] == expected_id
65
+ end
66
+ end
67
+ end
68
+
69
+ class MCPToolWrapper < Tool
70
+ attr_reader :mcp_tool, :client
71
+
72
+ def self.for(mcp_tool, client)
73
+ klass = Class.new(self) do
74
+ tool_name(mcp_tool["name"])
75
+ description(mcp_tool["description"] || "")
76
+
77
+ schema = mcp_tool.dig("inputSchema", "properties") || {}
78
+ required_fields = mcp_tool.dig("inputSchema", "required") || []
79
+ schema.each do |param_name, param_def|
80
+ input param_name.to_sym,
81
+ type: (param_def["type"] || "string").to_sym,
82
+ description: param_def["description"] || "",
83
+ required: required_fields.include?(param_name)
84
+ end
85
+ end
86
+ klass.new(mcp_tool, client)
87
+ end
88
+
89
+ def initialize(mcp_tool, client)
90
+ @mcp_tool = mcp_tool
91
+ @client = client
92
+ end
93
+
94
+ def call(**kwargs)
95
+ response = @client.send_request(
96
+ request: {
97
+ method: "tools/call",
98
+ params: { name: @mcp_tool["name"], arguments: kwargs.transform_keys(&:to_s) }
99
+ }
100
+ )
101
+ # Extract text content from MCP response
102
+ content = response.dig("result", "content") || []
103
+ content.filter_map { |c| c["text"] if c["type"] == "text" }.join("\n")
104
+ end
105
+ end
106
+ end
107
+
108
+ def self.tools_from_mcp(command:)
109
+ transport = MCP::StdioTransport.new(command: command)
110
+ response = transport.send_request(request: { method: "tools/list", params: {} })
111
+ tools = response.dig("result", "tools") || []
112
+ tools.map { |t| MCP::MCPToolWrapper.for(t, transport) }
113
+ end
114
+
115
+ def self.tool_from_mcp(command:, tool_name:)
116
+ transport = MCP::StdioTransport.new(command: command)
117
+ response = transport.send_request(request: { method: "tools/list", params: {} })
118
+ tools = response.dig("result", "tools") || []
119
+ tool_def = tools.find { |t| t["name"] == tool_name }
120
+
121
+ unless tool_def
122
+ available = tools.map { |t| t["name"] }.join(", ")
123
+ raise ArgumentError, "Tool #{tool_name.inspect} not found. Available: #{available}"
124
+ end
125
+
126
+ MCP::MCPToolWrapper.for(tool_def, transport)
127
+ end
128
+ end