Skip to content

Commit 905028d

Browse files
committed
wip
1 parent f3354e4 commit 905028d

4 files changed

Lines changed: 299 additions & 0 deletions

File tree

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ AllCops:
66
TargetRubyVersion: 3.2
77
NewCops: enable
88

9+
Lint/UnusedBlockArgument:
10+
AllowUnusedKeywordArguments: true
11+
912
Lint/UnusedMethodArgument:
1013
AllowUnusedKeywordArguments: true
1114

lib/tool_forge/tool_definition.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,59 @@ def param(name, type: :string, description: nil, required: true, default: nil)
3434
def execute(&block)
3535
@execute_block = block
3636
end
37+
38+
def to_ruby_llm_tool
39+
raise LoadError, 'RubyLLM is not loaded. Please require "ruby_llm" first.' unless defined?(RubyLLM::Tool)
40+
raise LoadError, 'RubyLLM is not loaded. Please require "ruby_llm" first.' if RubyLLM::Tool.nil?
41+
42+
definition = self
43+
44+
Class.new(RubyLLM::Tool) do
45+
description definition.description
46+
47+
definition.params.each do |param_def|
48+
param param_def[:name], type: param_def[:type], desc: param_def[:description]
49+
end
50+
51+
define_method(:execute) do |**args|
52+
definition.execute_block.call(**args)
53+
end
54+
end
55+
end
56+
57+
def to_mcp_tool
58+
raise LoadError, 'MCP SDK is not loaded. Please require "mcp" first.' unless defined?(MCP::Tool)
59+
raise LoadError, 'MCP SDK is not loaded. Please require "mcp" first.' if MCP::Tool.nil?
60+
61+
definition = self
62+
63+
Class.new(MCP::Tool) do
64+
description definition.description
65+
66+
# Build properties hash for input schema
67+
properties = {}
68+
required_params = []
69+
70+
definition.params.each do |param_def|
71+
prop = {
72+
type: param_def[:type].to_s
73+
}
74+
prop[:description] = param_def[:description] if param_def[:description]
75+
76+
properties[param_def[:name].to_s] = prop
77+
required_params << param_def[:name].to_s if param_def[:required]
78+
end
79+
80+
input_schema(
81+
properties: properties,
82+
required: required_params
83+
)
84+
85+
define_singleton_method(:call) do |server_context:, **args|
86+
result = definition.execute_block.call(**args)
87+
MCP::Tool::Response.new([{ type: 'text', text: result.to_s }])
88+
end
89+
end
90+
end
3791
end
3892
end
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# frozen_string_literal: true
2+
3+
require 'mcp'
4+
5+
# rubocop:disable RSpec/SpecFilePathFormat
6+
RSpec.describe ToolForge::ToolDefinition, '#to_mcp_tool' do
7+
# rubocop:enable RSpec/SpecFilePathFormat
8+
it 'raises an error if MCP::Tool is not loaded' do
9+
tool = described_class.new(:my_tool) do
10+
description 'A test tool'
11+
end
12+
13+
# Stub to simulate MCP not being loaded
14+
stub_const('MCP::Tool', nil)
15+
16+
expect { tool.to_mcp_tool }.to raise_error(LoadError, /MCP SDK is not loaded/)
17+
end
18+
19+
it 'returns a class that inherits from MCP::Tool' do
20+
tool = described_class.new(:my_tool) do
21+
description 'A test tool'
22+
end
23+
24+
tool_class = tool.to_mcp_tool
25+
expect(tool_class).to be_a(Class)
26+
expect(tool_class.ancestors).to include(MCP::Tool)
27+
end
28+
29+
it 'sets the tool description' do
30+
tool = described_class.new(:greeting_tool) do
31+
description 'Greets a user'
32+
end
33+
34+
tool_class = tool.to_mcp_tool
35+
36+
# MCP::Tool stores description at class level
37+
expect(tool_class.description).to eq('Greets a user')
38+
end
39+
40+
it 'defines input schema with parameters' do
41+
tool = described_class.new(:my_tool) do
42+
description 'A test tool'
43+
param :name, type: :string, description: 'User name', required: true
44+
param :age, type: :integer, description: 'User age', required: false
45+
end
46+
47+
tool_class = tool.to_mcp_tool
48+
schema = tool_class.input_schema.to_h
49+
50+
expect(schema[:properties]).to have_key('name')
51+
expect(schema[:properties]['name'][:type]).to eq('string')
52+
expect(schema[:properties]['name'][:description]).to eq('User name')
53+
54+
expect(schema[:properties]).to have_key('age')
55+
expect(schema[:properties]['age'][:type]).to eq('integer')
56+
expect(schema[:properties]['age'][:description]).to eq('User age')
57+
58+
expect(schema[:required]).to eq([:name])
59+
end
60+
61+
it 'creates a call method that executes the block' do
62+
tool = described_class.new(:greeting_tool) do
63+
description 'Greets a user'
64+
param :name, type: :string
65+
execute { |name:| "Hello, #{name}!" }
66+
end
67+
68+
tool_class = tool.to_mcp_tool
69+
70+
result = tool_class.call(server_context: nil, name: 'Alice')
71+
expect(result).to be_a(MCP::Tool::Response)
72+
expect(result.content.first[:text]).to eq('Hello, Alice!')
73+
end
74+
75+
it 'handles tools with default values' do
76+
tool = described_class.new(:greeting_tool) do
77+
description 'Greets a user'
78+
param :name, type: :string
79+
param :greeting, type: :string, default: 'Hello'
80+
81+
execute do |name:, greeting: 'Hello'|
82+
"#{greeting}, #{name}!"
83+
end
84+
end
85+
86+
tool_class = tool.to_mcp_tool
87+
88+
result1 = tool_class.call(server_context: nil, name: 'Bob')
89+
expect(result1.content.first[:text]).to eq('Hello, Bob!')
90+
91+
result2 = tool_class.call(server_context: nil, name: 'Bob', greeting: 'Hi')
92+
expect(result2.content.first[:text]).to eq('Hi, Bob!')
93+
end
94+
95+
it 'handles tools with multiple parameter types' do
96+
tool = described_class.new(:complex_tool) do
97+
description 'A complex tool'
98+
param :name, type: :string
99+
param :count, type: :integer
100+
param :active, type: :boolean
101+
102+
execute do |name:, count:, active:|
103+
{ name: name, count: count, active: active }.to_s
104+
end
105+
end
106+
107+
tool_class = tool.to_mcp_tool
108+
schema = tool_class.input_schema.to_h
109+
110+
expect(schema[:properties]['name'][:type]).to eq('string')
111+
expect(schema[:properties]['count'][:type]).to eq('integer')
112+
expect(schema[:properties]['active'][:type]).to eq('boolean')
113+
114+
result = tool_class.call(server_context: nil, name: 'test', count: 5, active: true)
115+
expect(result).to be_a(MCP::Tool::Response)
116+
end
117+
118+
it 'converts type symbols to JSON schema types' do
119+
tool = described_class.new(:type_test) do
120+
description 'Tests type conversion'
121+
param :str, type: :string
122+
param :int, type: :integer
123+
param :bool, type: :boolean
124+
param :arr, type: :array
125+
param :obj, type: :object
126+
end
127+
128+
tool_class = tool.to_mcp_tool
129+
schema = tool_class.input_schema.to_h
130+
131+
expect(schema[:properties]['str'][:type]).to eq('string')
132+
expect(schema[:properties]['int'][:type]).to eq('integer')
133+
expect(schema[:properties]['bool'][:type]).to eq('boolean')
134+
expect(schema[:properties]['arr'][:type]).to eq('array')
135+
expect(schema[:properties]['obj'][:type]).to eq('object')
136+
end
137+
end
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# frozen_string_literal: true
2+
3+
require 'ruby_llm'
4+
5+
# rubocop:disable RSpec/SpecFilePathFormat
6+
RSpec.describe ToolForge::ToolDefinition, '#to_ruby_llm_tool' do
7+
# rubocop:enable RSpec/SpecFilePathFormat
8+
it 'raises an error if RubyLLM::Tool is not loaded' do
9+
tool = described_class.new(:my_tool) do
10+
description 'A test tool'
11+
end
12+
13+
# Stub to simulate RubyLLM not being loaded
14+
stub_const('RubyLLM::Tool', nil)
15+
16+
expect { tool.to_ruby_llm_tool }.to raise_error(LoadError, /RubyLLM is not loaded/)
17+
end
18+
19+
it 'returns a class that inherits from RubyLLM::Tool' do
20+
tool = described_class.new(:my_tool) do
21+
description 'A test tool'
22+
end
23+
24+
tool_class = tool.to_ruby_llm_tool
25+
expect(tool_class).to be_a(Class)
26+
expect(tool_class.ancestors).to include(RubyLLM::Tool)
27+
end
28+
29+
it 'sets the tool description' do
30+
tool = described_class.new(:greeting_tool) do
31+
description 'Greets a user'
32+
end
33+
34+
tool_class = tool.to_ruby_llm_tool
35+
instance = tool_class.new
36+
37+
expect(instance.class.description).to eq('Greets a user')
38+
end
39+
40+
it 'creates an execute method that calls the block' do
41+
tool = described_class.new(:greeting_tool) do
42+
description 'Greets a user'
43+
param :name, type: :string
44+
execute { |name:| "Hello, #{name}!" }
45+
end
46+
47+
tool_class = tool.to_ruby_llm_tool
48+
instance = tool_class.new
49+
50+
result = instance.execute(name: 'Alice')
51+
expect(result).to eq('Hello, Alice!')
52+
end
53+
54+
it 'handles tools with default values' do
55+
tool = described_class.new(:greeting_tool) do
56+
description 'Greets a user'
57+
param :name, type: :string
58+
param :greeting, type: :string, default: 'Hello'
59+
60+
execute do |name:, greeting: 'Hello'|
61+
"#{greeting}, #{name}!"
62+
end
63+
end
64+
65+
tool_class = tool.to_ruby_llm_tool
66+
instance = tool_class.new
67+
68+
expect(instance.execute(name: 'Bob')).to eq('Hello, Bob!')
69+
expect(instance.execute(name: 'Bob', greeting: 'Hi')).to eq('Hi, Bob!')
70+
end
71+
72+
it 'handles tools with multiple parameter types' do
73+
tool = described_class.new(:complex_tool) do
74+
description 'A complex tool'
75+
param :name, type: :string
76+
param :count, type: :integer
77+
param :active, type: :boolean
78+
param :tags, type: :array
79+
param :metadata, type: :object
80+
81+
execute do |name:, count:, active:, tags:, metadata:|
82+
{ name: name, count: count, active: active, tags: tags, metadata: metadata }
83+
end
84+
end
85+
86+
tool_class = tool.to_ruby_llm_tool
87+
instance = tool_class.new
88+
89+
result = instance.execute(
90+
name: 'test',
91+
count: 5,
92+
active: true,
93+
tags: %w[a b],
94+
metadata: { key: 'value' }
95+
)
96+
97+
expect(result).to eq(
98+
name: 'test',
99+
count: 5,
100+
active: true,
101+
tags: %w[a b],
102+
metadata: { key: 'value' }
103+
)
104+
end
105+
end

0 commit comments

Comments
 (0)