Skip to content

Commit 37fc283

Browse files
committed
Implement tools
1 parent 3ab7245 commit 37fc283

4 files changed

Lines changed: 315 additions & 3 deletions

File tree

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ gem "irb"
99
gem "rake", "~> 13.0"
1010

1111
gem "rspec", "~> 3.0"
12+
13+
gem "thor", "~> 1.3"

Gemfile.lock

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
PATH
2+
remote: .
3+
specs:
4+
modelcontextprotocol (0.1.0)
5+
6+
GEM
7+
remote: https://rubygems.org/
8+
specs:
9+
date (3.4.1)
10+
diff-lcs (1.5.1)
11+
io-console (0.8.0)
12+
irb (1.15.1)
13+
pp (>= 0.6.0)
14+
rdoc (>= 4.0.0)
15+
reline (>= 0.4.2)
16+
pp (0.6.2)
17+
prettyprint
18+
prettyprint (0.2.0)
19+
psych (5.2.3)
20+
date
21+
stringio
22+
rake (13.2.1)
23+
rdoc (6.12.0)
24+
psych (>= 4.0.0)
25+
reline (0.6.0)
26+
io-console (~> 0.5)
27+
rspec (3.13.0)
28+
rspec-core (~> 3.13.0)
29+
rspec-expectations (~> 3.13.0)
30+
rspec-mocks (~> 3.13.0)
31+
rspec-core (3.13.2)
32+
rspec-support (~> 3.13.0)
33+
rspec-expectations (3.13.3)
34+
diff-lcs (>= 1.2.0, < 2.0)
35+
rspec-support (~> 3.13.0)
36+
rspec-mocks (3.13.2)
37+
diff-lcs (>= 1.2.0, < 2.0)
38+
rspec-support (~> 3.13.0)
39+
rspec-support (3.13.2)
40+
stringio (3.1.5)
41+
thor (1.3.2)
42+
43+
PLATFORMS
44+
arm64-darwin-24
45+
ruby
46+
47+
DEPENDENCIES
48+
irb
49+
modelcontextprotocol!
50+
rake (~> 13.0)
51+
rspec (~> 3.0)
52+
thor (~> 1.3)
53+
54+
BUNDLED WITH
55+
2.6.5

lib/modelcontextprotocol.rb

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,159 @@
22

33
require_relative "modelcontextprotocol/version"
44

5+
require 'json'
6+
57
module ModelContextProtocol
68
class Error < StandardError; end
7-
# Your code goes here...
9+
10+
# Module to automatically convert snake_case keys to camelCase when serializing to JSON.
11+
module CamelCaseJson
12+
def to_json(*options)
13+
JSON.generate(camelize_hash_keys(to_h), *options)
14+
end
15+
16+
private
17+
18+
def camelize_hash_keys(obj)
19+
case obj
20+
when Array
21+
obj.map { |item| camelize_hash_keys(item) }
22+
when Hash
23+
obj.each_with_object({}) do |(key, value), new_hash|
24+
new_key = camelize(key.to_s)
25+
new_hash[new_key] = camelize_hash_keys(value)
26+
end
27+
else
28+
obj
29+
end
30+
end
31+
32+
def camelize(snake_str)
33+
parts = snake_str.split('_')
34+
parts[0] + parts[1..-1].map(&:capitalize).join
35+
end
36+
end
37+
38+
# Represents a single property for an input schema, you filthy fuckface.
39+
class Property
40+
include CamelCaseJson
41+
42+
attr_accessor :name, :type, :description, :required
43+
44+
# @param name [String] The property name.
45+
# @param type [String] The type of the property (e.g., "string", "number").
46+
# @param description [String] Description of the property.
47+
# @param required [Boolean] Whether the property is required.
48+
def initialize(name:, type:, description:, required: false)
49+
@name = name
50+
@type = type
51+
@description = description
52+
@required = required
53+
end
54+
55+
def to_h
56+
{
57+
name: name,
58+
type: type,
59+
description: description,
60+
required: required
61+
}
62+
end
63+
end
64+
65+
# Represents the input schema for a tool with properties as a collection of Property objects.
66+
class Input
67+
include CamelCaseJson
68+
69+
attr_accessor :type, :properties
70+
71+
# @param type [String] The type of the input (default: "object").
72+
# @param properties [Array<Property>] An array of Property objects.
73+
def initialize(type: "object", properties: [])
74+
@type = type
75+
@properties = properties
76+
end
77+
78+
# Builder method for adding a property.
79+
# @param name [String] The property name.
80+
# @param type [String] The type of the property.
81+
# @param description [String] Description of the property.
82+
# @param required [Boolean] Whether the property is required.
83+
# @return [Input] Returns self for chaining.
84+
def property(name:, type:, description:, required: false)
85+
@properties << Property.new(name: name, type: type, description: description, required: required)
86+
self
87+
end
88+
89+
def to_h
90+
{
91+
type: type,
92+
properties: properties.map(&:to_h)
93+
}
94+
end
95+
end
96+
97+
# Represents a tool definition in MCP.
98+
class Tool
99+
include CamelCaseJson
100+
101+
attr_accessor :name, :description, :input_schema
102+
103+
# @param name [String] Unique identifier for the tool.
104+
# @param description [String] Human-readable description of the tool.
105+
def initialize(name:, description:)
106+
@name = name
107+
@description = description
108+
@input_schema = nil
109+
end
110+
111+
# Builder method for setting up the input schema.
112+
# @param type [String] The type for the input schema (default: "object").
113+
# @yieldparam [Input] Yields the input instance to allow adding properties.
114+
# @return [Tool] Returns self for chaining.
115+
def input(type: "object")
116+
@input_schema ||= Input.new(type: type)
117+
yield(@input_schema) if block_given?
118+
self
119+
end
120+
121+
def to_h
122+
{
123+
name: name,
124+
description: description,
125+
input_schema: input_schema ? input_schema.to_h : nil
126+
}
127+
end
128+
end
129+
130+
# Represents a collection of tools with pagination.
131+
class Tools
132+
include CamelCaseJson
133+
134+
attr_accessor :id, :tools, :next_cursor
135+
136+
# id defaults to 1, and next_cursor is optional.
137+
def initialize(id: 1, tools: [], next_cursor: nil)
138+
@id = id
139+
@tools = tools
140+
@next_cursor = next_cursor
141+
end
142+
143+
# Builder method for adding a tool.
144+
def add_tool(tool)
145+
@tools << tool
146+
self
147+
end
148+
149+
def to_h
150+
{
151+
jsonrpc: "2.0",
152+
id: id,
153+
result: {
154+
tools: tools.map(&:to_h),
155+
next_cursor: next_cursor
156+
}
157+
}
158+
end
159+
end
8160
end

spec/modelcontextprotocol_spec.rb

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,111 @@
44
it "has a version number" do
55
expect(ModelContextProtocol::VERSION).not_to be nil
66
end
7+
end
8+
9+
RSpec.describe ModelContextProtocol::Tool do
10+
describe "#input" do
11+
it "allows building an input schema with properties" do
12+
tool = ModelContextProtocol::Tool.new(name: "test_tool", description: "A test tool")
13+
tool.input do |inp|
14+
inp.property(
15+
name: "test_property",
16+
type: "string",
17+
description: "A test property",
18+
required: true
19+
)
20+
end
21+
22+
input_schema = tool.to_h[:input_schema]
23+
expect(input_schema[:type]).to eq("object")
24+
expect(input_schema[:properties].first[:name]).to eq("test_property")
25+
expect(input_schema[:properties].first[:required]).to eq(true)
26+
end
27+
28+
it "serializes to camelCase JSON" do
29+
tool = ModelContextProtocol::Tool.new(name: "test_tool", description: "A test tool")
30+
tool.input do |inp|
31+
inp.property(
32+
name: "test_property",
33+
type: "string",
34+
description: "A test property",
35+
required: true
36+
)
37+
end
38+
39+
json_output = tool.to_json
40+
parsed = JSON.parse(json_output)
41+
expect(parsed).to have_key("name")
42+
expect(parsed).to have_key("description")
43+
expect(parsed).to have_key("inputSchema")
44+
expect(parsed["inputSchema"]).to have_key("type")
45+
expect(parsed["inputSchema"]).to have_key("properties")
46+
expect(parsed["inputSchema"]["properties"].first).to have_key("name")
47+
expect(parsed["inputSchema"]["properties"].first).to have_key("required")
48+
end
49+
end
50+
51+
describe "Property builder" do
52+
it "creates properties correctly" do
53+
input = ModelContextProtocol::Input.new
54+
input.property(name: "location", type: "string", description: "City name", required: true)
55+
expect(input.properties.size).to eq(1)
56+
prop = input.properties.first
57+
expect(prop.name).to eq("location")
58+
expect(prop.type).to eq("string")
59+
expect(prop.description).to eq("City name")
60+
expect(prop.required).to be true
61+
end
62+
end
63+
end
64+
65+
RSpec.describe ModelContextProtocol::Tools do
66+
it "serializes the tools collection exactly as expected" do
67+
# Build a tool with input schema
68+
tool = ModelContextProtocol::Tool.new(
69+
name: "get_weather",
70+
description: "Get current weather information for a location"
71+
).input do |inp|
72+
inp.property(
73+
name: "location",
74+
type: "string",
75+
description: "City name or zip code",
76+
required: true
77+
)
78+
end
79+
80+
# Build the tools collection
81+
tools = ModelContextProtocol::Tools.new(next_cursor: "next-page-cursor")
82+
tools.add_tool(tool)
83+
84+
# Expected output hash
85+
expected_hash = {
86+
"jsonrpc" => "2.0",
87+
"id" => 1,
88+
"result" => {
89+
"tools" => [
90+
{
91+
"name" => "get_weather",
92+
"description" => "Get current weather information for a location",
93+
"inputSchema" => {
94+
"type" => "object",
95+
"properties" => [
96+
{
97+
"name" => "location",
98+
"type" => "string",
99+
"description" => "City name or zip code",
100+
"required" => true
101+
}
102+
]
103+
}
104+
}
105+
],
106+
"nextCursor" => "next-page-cursor"
107+
}
108+
}
7109

8-
it "does something useful" do
9-
expect(false).to eq(true)
110+
json_output = tools.to_json
111+
parsed_output = JSON.parse(json_output)
112+
expect(parsed_output).to eq(expected_hash)
10113
end
11114
end

0 commit comments

Comments
 (0)