Add basic HTTP client support with pluggable transports
This commit is contained in:
parent
eb0d9c0e6d
commit
8b764428b1
3 changed files with 609 additions and 8 deletions
212
test/mcp/client/http_test.rb
Normal file
212
test/mcp/client/http_test.rb
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "faraday"
|
||||
require "webmock/minitest"
|
||||
require "mcp/client/http"
|
||||
require "mcp/client/tool"
|
||||
require "mcp/client"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
class HTTPTest < Minitest::Test
|
||||
def test_raises_load_error_when_faraday_not_available
|
||||
client = HTTP.new(url: url)
|
||||
|
||||
# simulate Faraday not being available
|
||||
HTTP.any_instance.stubs(:require).with("faraday").raises(LoadError, "cannot load such file -- faraday")
|
||||
|
||||
error = assert_raises(LoadError) do
|
||||
# This should immediately try to instantiate the client and fail
|
||||
client.send_request(request: {})
|
||||
end
|
||||
|
||||
assert_includes(error.message, "The 'faraday' gem is required to use the MCP client HTTP transport")
|
||||
assert_includes(error.message, "Add it to your Gemfile: gem 'faraday', '>= 2.0'")
|
||||
end
|
||||
|
||||
def test_headers_are_added_to_the_request
|
||||
headers = { "Authorization" => "Bearer token" }
|
||||
client = HTTP.new(url: url, headers: headers)
|
||||
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
|
||||
stub_request(:post, url)
|
||||
.with(
|
||||
headers: {
|
||||
"Authorization" => "Bearer token",
|
||||
"Content-Type" => "application/json",
|
||||
},
|
||||
body: request.to_json,
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: { result: { tools: [] } }.to_json,
|
||||
)
|
||||
|
||||
# The test passes if the request is made with the correct headers
|
||||
# If headers are wrong, the stub_request won't match and will raise
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
def test_send_request_returns_faraday_response
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
|
||||
stub_request(:post, url)
|
||||
.with(body: request.to_json)
|
||||
.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: { result: { tools: [] } }.to_json,
|
||||
)
|
||||
|
||||
response = client.send_request(request: request)
|
||||
assert_instance_of(Hash, response)
|
||||
assert_equal({ "result" => { "tools" => [] } }, response)
|
||||
end
|
||||
|
||||
def test_send_request_raises_bad_request_error
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
|
||||
stub_request(:post, url)
|
||||
.with(body: request.to_json)
|
||||
.to_return(status: 400)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_equal("The tools/list request is invalid", error.message)
|
||||
assert_equal(:bad_request, error.error_type)
|
||||
assert_equal({ method: "tools/list", params: nil }, error.request)
|
||||
end
|
||||
|
||||
def test_send_request_raises_unauthorized_error
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
|
||||
stub_request(:post, url)
|
||||
.with(body: request.to_json)
|
||||
.to_return(status: 401)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_equal("You are unauthorized to make tools/list requests", error.message)
|
||||
assert_equal(:unauthorized, error.error_type)
|
||||
assert_equal({ method: "tools/list", params: nil }, error.request)
|
||||
end
|
||||
|
||||
def test_send_request_raises_forbidden_error
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
|
||||
stub_request(:post, url)
|
||||
.with(body: request.to_json)
|
||||
.to_return(status: 403)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_equal("You are forbidden to make tools/list requests", error.message)
|
||||
assert_equal(:forbidden, error.error_type)
|
||||
assert_equal({ method: "tools/list", params: nil }, error.request)
|
||||
end
|
||||
|
||||
def test_send_request_raises_not_found_error
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
|
||||
stub_request(:post, url)
|
||||
.with(body: request.to_json)
|
||||
.to_return(status: 404)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_equal("The tools/list request is not found", error.message)
|
||||
assert_equal(:not_found, error.error_type)
|
||||
assert_equal({ method: "tools/list", params: nil }, error.request)
|
||||
end
|
||||
|
||||
def test_send_request_raises_unprocessable_entity_error
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
|
||||
stub_request(:post, url)
|
||||
.with(body: request.to_json)
|
||||
.to_return(status: 422)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_equal("The tools/list request is unprocessable", error.message)
|
||||
assert_equal(:unprocessable_entity, error.error_type)
|
||||
assert_equal({ method: "tools/list", params: nil }, error.request)
|
||||
end
|
||||
|
||||
def test_send_request_raises_internal_error
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
|
||||
stub_request(:post, url)
|
||||
.with(body: request.to_json)
|
||||
.to_return(status: 500)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_equal("Internal error handling tools/list request", error.message)
|
||||
assert_equal(:internal_error, error.error_type)
|
||||
assert_equal({ method: "tools/list", params: nil }, error.request)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_request(method, url)
|
||||
WebMock.stub_request(method, url)
|
||||
end
|
||||
|
||||
def url
|
||||
"http://example.com"
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= HTTP.new(url: url)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
33
test/mcp/client/tool_test.rb
Normal file
33
test/mcp/client/tool_test.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "mcp/client/tool"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
class ToolTest < Minitest::Test
|
||||
def setup
|
||||
@tool = Tool.new(
|
||||
name: "test_tool",
|
||||
description: "A test tool",
|
||||
input_schema: { "type" => "object", "properties" => { "foo" => { "type" => "string" } } },
|
||||
)
|
||||
end
|
||||
|
||||
def test_name_returns_name
|
||||
assert_equal("test_tool", @tool.name)
|
||||
end
|
||||
|
||||
def test_description_returns_description
|
||||
assert_equal("A test tool", @tool.description)
|
||||
end
|
||||
|
||||
def test_input_schema_returns_input_schema
|
||||
assert_equal(
|
||||
{ "type" => "object", "properties" => { "foo" => { "type" => "string" } } },
|
||||
@tool.input_schema,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
60
test/mcp/client_test.rb
Normal file
60
test/mcp/client_test.rb
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "securerandom"
|
||||
|
||||
module MCP
|
||||
class ClientTest < Minitest::Test
|
||||
def test_tools_sends_request_to_transport_and_returns_tools_array
|
||||
transport = mock
|
||||
mock_response = {
|
||||
"result" => {
|
||||
"tools" => [
|
||||
{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} },
|
||||
{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Only checking for the essential parts of the request
|
||||
transport.expects(:send_request).with do |args|
|
||||
args in { request: { method: "tools/list", jsonrpc: "2.0" } }
|
||||
end.returns(mock_response).once
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
tools = client.tools
|
||||
|
||||
assert_equal(2, tools.size)
|
||||
assert_equal("tool1", tools.first.name)
|
||||
assert_equal("tool2", tools.last.name)
|
||||
end
|
||||
|
||||
def test_call_tool_sends_request_to_transport_and_returns_content
|
||||
transport = mock
|
||||
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
|
||||
arguments = { foo: "bar" }
|
||||
mock_response = {
|
||||
"result" => { "content" => "result" },
|
||||
}
|
||||
|
||||
# Only checking for the essential parts of the request
|
||||
transport.expects(:send_request).with do |args|
|
||||
args in {
|
||||
request: {
|
||||
method: "tools/call",
|
||||
jsonrpc: "2.0",
|
||||
params: {
|
||||
name: "tool1",
|
||||
arguments: arguments,
|
||||
},
|
||||
},
|
||||
}
|
||||
end.returns(mock_response).once
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
result = client.call_tool(tool: tool, arguments: arguments)
|
||||
|
||||
assert_equal("result", result)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue