Skip to content

Commit 9ad0d99

Browse files
committed
Add basic HTTP client support
1 parent 5eacee6 commit 9ad0d99

File tree

10 files changed

+469
-0
lines changed

10 files changed

+469
-0
lines changed

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ gem "activesupport"
2222
gem "debug"
2323
gem "rake", "~> 13.0"
2424
gem "sorbet-static-and-runtime"
25+
26+
group :test do
27+
gem "webmock"
28+
end

lib/mcp/client.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
# require "json_rpc_handler"
4+
# require_relative "shared/instrumentation"
5+
# require_relative "shared/methods"
6+
7+
module ModelContextProtocol
8+
module Client
9+
# Can be made an abstract class if we need shared behavior
10+
end
11+
end

lib/mcp/client/http.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
# require "json_rpc_handler"
4+
# require_relative "shared/instrumentation"
5+
# require_relative "shared/methods"
6+
7+
module ModelContextProtocol
8+
module Client
9+
class Http
10+
DEFAULT_VERSION = "0.1.0"
11+
12+
attr_reader :url, :version
13+
14+
def initialize(url:, version: DEFAULT_VERSION)
15+
@url = url
16+
@version = version
17+
end
18+
19+
def tools
20+
response = client.post(
21+
"",
22+
method: "tools/list",
23+
jsonrpc: "2.0",
24+
id: request_id,
25+
mcp: { method: "tools/list", jsonrpc: "2.0", id: request_id },
26+
).body
27+
28+
::ModelContextProtocol::Client::Tools.new(response)
29+
end
30+
31+
def call_tool(tool:, input:)
32+
response = client.post(
33+
"",
34+
{
35+
jsonrpc: "2.0",
36+
id: request_id,
37+
method: "tools/call",
38+
params: { name: tool.name, arguments: input },
39+
mcp: { jsonrpc: "2.0", id: request_id, method: "tools/call", params: { name: tool.name, arguments: input } },
40+
},
41+
).body
42+
43+
response.dig("result", "content", 0, "text")
44+
end
45+
46+
private
47+
48+
def client
49+
@client ||= Faraday.new(url) do |faraday|
50+
faraday.request(:json)
51+
faraday.response(:json)
52+
# TODO: error middleware?
53+
end
54+
end
55+
56+
def request_id
57+
SecureRandom.uuid_v7
58+
end
59+
end
60+
end
61+
end

lib/mcp/client/tool.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# typed: false
2+
# frozen_string_literal: true
3+
4+
module ModelContextProtocol
5+
module Client
6+
class Tool
7+
attr_reader :payload
8+
9+
def initialize(payload)
10+
@payload = payload
11+
end
12+
13+
def name
14+
payload["name"]
15+
end
16+
17+
def description
18+
payload["description"]
19+
end
20+
21+
def input_schema
22+
payload["inputSchema"]
23+
end
24+
end
25+
end
26+
end

lib/mcp/client/tools.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# typed: false
2+
# frozen_string_literal: true
3+
4+
module ModelContextProtocol
5+
module Client
6+
class Tools
7+
include Enumerable
8+
9+
attr_reader :response
10+
11+
def initialize(response)
12+
@response = response
13+
end
14+
15+
def each(&block)
16+
tools.each(&block)
17+
end
18+
19+
def all
20+
tools
21+
end
22+
23+
private
24+
25+
def tools
26+
@tools ||= @response.dig("result", "tools")&.map { |tool| Tool.new(tool) } || []
27+
end
28+
end
29+
end
30+
end

mcp.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
2727
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
2828
spec.require_paths = ["lib"]
2929

30+
spec.add_dependency("faraday", ">= 2.0")
3031
spec.add_dependency("json_rpc_handler", "~> 0.1")
3132
spec.add_dependency("json-schema", ">= 4.1")
3233
end
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "faraday"
5+
require "securerandom"
6+
require "webmock/minitest"
7+
8+
module ModelContextProtocol
9+
module Client
10+
class HttpTest < Minitest::Test
11+
def test_initialization_with_default_version
12+
assert_equal("0.1.0", client.version)
13+
assert_equal(url, client.url)
14+
end
15+
16+
def test_initialization_with_custom_version
17+
custom_version = "1.2.3"
18+
client = Http.new(url:, version: custom_version)
19+
assert_equal(custom_version, client.version)
20+
end
21+
22+
def test_tools_returns_tools_instance
23+
stub_request(:post, url)
24+
.with(
25+
body: {
26+
method: "tools/list",
27+
jsonrpc: "2.0",
28+
id: mock_request_id,
29+
mcp: {
30+
method: "tools/list",
31+
jsonrpc: "2.0",
32+
id: mock_request_id,
33+
},
34+
},
35+
)
36+
.to_return(
37+
status: 200,
38+
headers: {
39+
"Content-Type" => "application/json",
40+
},
41+
body: {
42+
result: {
43+
tools: [
44+
{
45+
name: "test_tool",
46+
description: "A test tool",
47+
inputSchema: {
48+
type: "object",
49+
properties: {},
50+
},
51+
},
52+
],
53+
},
54+
}.to_json,
55+
)
56+
57+
tools = client.tools
58+
assert_instance_of(Tools, tools)
59+
assert_equal(1, tools.count)
60+
assert_equal("test_tool", tools.first.name)
61+
end
62+
63+
def test_call_tool_returns_tool_response
64+
tool = Tool.new(
65+
"name" => "test_tool",
66+
"description" => "A test tool",
67+
"inputSchema" => {
68+
"type" => "object",
69+
"properties" => {},
70+
},
71+
)
72+
input = { "param" => "value" }
73+
74+
stub_request(:post, url)
75+
.with(
76+
body: {
77+
jsonrpc: "2.0",
78+
id: mock_request_id,
79+
method: "tools/call",
80+
params: {
81+
name: "test_tool",
82+
arguments: input,
83+
},
84+
mcp: {
85+
jsonrpc: "2.0",
86+
id: mock_request_id,
87+
method: "tools/call",
88+
params: {
89+
name: "test_tool",
90+
arguments: input,
91+
},
92+
},
93+
},
94+
)
95+
.to_return(
96+
status: 200,
97+
headers: {
98+
"Content-Type" => "application/json",
99+
},
100+
body: {
101+
result: {
102+
content: [
103+
{
104+
text: "Tool response",
105+
},
106+
],
107+
},
108+
}.to_json,
109+
)
110+
111+
response = client.call_tool(tool: tool, input: input)
112+
assert_equal("Tool response", response)
113+
end
114+
115+
def test_call_tool_handles_empty_response
116+
tool = Tool.new(
117+
"name" => "test_tool",
118+
"description" => "A test tool",
119+
"inputSchema" => {
120+
"type" => "object",
121+
"properties" => {},
122+
},
123+
)
124+
input = { "param" => "value" }
125+
126+
stub_request(:post, url)
127+
.with(
128+
body: {
129+
jsonrpc: "2.0",
130+
id: mock_request_id,
131+
method: "tools/call",
132+
params: {
133+
name: "test_tool",
134+
arguments: input,
135+
},
136+
mcp: {
137+
jsonrpc: "2.0",
138+
id: mock_request_id,
139+
method: "tools/call",
140+
params: {
141+
name: "test_tool",
142+
arguments: input,
143+
},
144+
},
145+
},
146+
)
147+
.to_return(
148+
status: 200,
149+
headers: {
150+
"Content-Type" => "application/json",
151+
},
152+
body: {
153+
result: {
154+
content: [],
155+
},
156+
}.to_json,
157+
)
158+
159+
response = client.call_tool(tool: tool, input: input)
160+
assert_nil(response)
161+
end
162+
163+
private
164+
165+
def stub_request(method, url)
166+
WebMock.stub_request(method, url)
167+
end
168+
169+
def mock_request_id
170+
"random_request_id"
171+
end
172+
173+
def url
174+
"http://example.com"
175+
end
176+
177+
def client
178+
@client ||= begin
179+
client = Http.new(url:)
180+
client.stubs(:request_id).returns(mock_request_id)
181+
client
182+
end
183+
end
184+
end
185+
end
186+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module ModelContextProtocol
6+
module Client
7+
class ToolTest < Minitest::Test
8+
def test_name_returns_name_from_payload
9+
tool = Tool.new("name" => "test_tool")
10+
assert_equal("test_tool", tool.name)
11+
end
12+
13+
def test_name_returns_nil_when_not_in_payload
14+
tool = Tool.new({})
15+
assert_nil(tool.name)
16+
end
17+
18+
def test_description_returns_description_from_payload
19+
tool = Tool.new("description" => "A test tool")
20+
assert_equal("A test tool", tool.description)
21+
end
22+
23+
def test_description_returns_nil_when_not_in_payload
24+
tool = Tool.new({})
25+
assert_nil(tool.description)
26+
end
27+
28+
def test_input_schema_returns_input_schema_from_payload
29+
schema = { "type" => "object", "properties" => { "foo" => { "type" => "string" } } }
30+
tool = Tool.new("inputSchema" => schema)
31+
assert_equal(schema, tool.input_schema)
32+
end
33+
34+
def test_input_schema_returns_nil_when_not_in_payload
35+
tool = Tool.new({})
36+
assert_nil(tool.input_schema)
37+
end
38+
39+
def test_payload_is_accessible
40+
payload = { "name" => "test", "description" => "desc", "inputSchema" => {} }
41+
tool = Tool.new(payload)
42+
assert_equal(payload, tool.payload)
43+
end
44+
end
45+
end
46+
end

0 commit comments

Comments
 (0)