Merge pull request #281 from koic/client_raise_on_error_response
Fix client methods silently swallowing JSON-RPC error responses
This commit is contained in:
commit
11f2e05945
2 changed files with 133 additions and 48 deletions
|
|
@ -6,6 +6,27 @@ require_relative "client/tool"
|
||||||
|
|
||||||
module MCP
|
module MCP
|
||||||
class Client
|
class Client
|
||||||
|
class ServerError < StandardError
|
||||||
|
attr_reader :code, :data
|
||||||
|
|
||||||
|
def initialize(message, code:, data: nil)
|
||||||
|
super(message)
|
||||||
|
@code = code
|
||||||
|
@data = data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class RequestHandlerError < StandardError
|
||||||
|
attr_reader :error_type, :original_error, :request
|
||||||
|
|
||||||
|
def initialize(message, request, error_type: :internal_error, original_error: nil)
|
||||||
|
super(message)
|
||||||
|
@request = request
|
||||||
|
@error_type = error_type
|
||||||
|
@original_error = original_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Initializes a new MCP::Client instance.
|
# Initializes a new MCP::Client instance.
|
||||||
#
|
#
|
||||||
# @param transport [Object] The transport object to use for communication with the server.
|
# @param transport [Object] The transport object to use for communication with the server.
|
||||||
|
|
@ -33,11 +54,7 @@ module MCP
|
||||||
# puts tool.name
|
# puts tool.name
|
||||||
# end
|
# end
|
||||||
def tools
|
def tools
|
||||||
response = transport.send_request(request: {
|
response = request(method: "tools/list")
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
||||||
id: request_id,
|
|
||||||
method: "tools/list",
|
|
||||||
})
|
|
||||||
|
|
||||||
response.dig("result", "tools")&.map do |tool|
|
response.dig("result", "tools")&.map do |tool|
|
||||||
Tool.new(
|
Tool.new(
|
||||||
|
|
@ -53,11 +70,7 @@ module MCP
|
||||||
#
|
#
|
||||||
# @return [Array<Hash>] An array of available resources.
|
# @return [Array<Hash>] An array of available resources.
|
||||||
def resources
|
def resources
|
||||||
response = transport.send_request(request: {
|
response = request(method: "resources/list")
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
||||||
id: request_id,
|
|
||||||
method: "resources/list",
|
|
||||||
})
|
|
||||||
|
|
||||||
response.dig("result", "resources") || []
|
response.dig("result", "resources") || []
|
||||||
end
|
end
|
||||||
|
|
@ -67,11 +80,7 @@ module MCP
|
||||||
#
|
#
|
||||||
# @return [Array<Hash>] An array of available resource templates.
|
# @return [Array<Hash>] An array of available resource templates.
|
||||||
def resource_templates
|
def resource_templates
|
||||||
response = transport.send_request(request: {
|
response = request(method: "resources/templates/list")
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
||||||
id: request_id,
|
|
||||||
method: "resources/templates/list",
|
|
||||||
})
|
|
||||||
|
|
||||||
response.dig("result", "resourceTemplates") || []
|
response.dig("result", "resourceTemplates") || []
|
||||||
end
|
end
|
||||||
|
|
@ -81,11 +90,7 @@ module MCP
|
||||||
#
|
#
|
||||||
# @return [Array<Hash>] An array of available prompts.
|
# @return [Array<Hash>] An array of available prompts.
|
||||||
def prompts
|
def prompts
|
||||||
response = transport.send_request(request: {
|
response = request(method: "prompts/list")
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
||||||
id: request_id,
|
|
||||||
method: "prompts/list",
|
|
||||||
})
|
|
||||||
|
|
||||||
response.dig("result", "prompts") || []
|
response.dig("result", "prompts") || []
|
||||||
end
|
end
|
||||||
|
|
@ -119,12 +124,7 @@ module MCP
|
||||||
params[:_meta] = { progressToken: progress_token }
|
params[:_meta] = { progressToken: progress_token }
|
||||||
end
|
end
|
||||||
|
|
||||||
transport.send_request(request: {
|
request(method: "tools/call", params: params)
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
||||||
id: request_id,
|
|
||||||
method: "tools/call",
|
|
||||||
params: params,
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Reads a resource from the server by URI and returns the contents.
|
# Reads a resource from the server by URI and returns the contents.
|
||||||
|
|
@ -132,12 +132,7 @@ module MCP
|
||||||
# @param uri [String] The URI of the resource to read.
|
# @param uri [String] The URI of the resource to read.
|
||||||
# @return [Array<Hash>] An array of resource contents (text or blob).
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
||||||
def read_resource(uri:)
|
def read_resource(uri:)
|
||||||
response = transport.send_request(request: {
|
response = request(method: "resources/read", params: { uri: uri })
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
||||||
id: request_id,
|
|
||||||
method: "resources/read",
|
|
||||||
params: { uri: uri },
|
|
||||||
})
|
|
||||||
|
|
||||||
response.dig("result", "contents") || []
|
response.dig("result", "contents") || []
|
||||||
end
|
end
|
||||||
|
|
@ -147,31 +142,34 @@ module MCP
|
||||||
# @param name [String] The name of the prompt to get.
|
# @param name [String] The name of the prompt to get.
|
||||||
# @return [Hash] A hash containing the prompt details.
|
# @return [Hash] A hash containing the prompt details.
|
||||||
def get_prompt(name:)
|
def get_prompt(name:)
|
||||||
response = transport.send_request(request: {
|
response = request(method: "prompts/get", params: { name: name })
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
||||||
id: request_id,
|
|
||||||
method: "prompts/get",
|
|
||||||
params: { name: name },
|
|
||||||
})
|
|
||||||
|
|
||||||
response.fetch("result", {})
|
response.fetch("result", {})
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def request(method:, params: nil)
|
||||||
|
request_body = {
|
||||||
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||||
|
id: request_id,
|
||||||
|
method: method,
|
||||||
|
}
|
||||||
|
request_body[:params] = params if params
|
||||||
|
|
||||||
|
response = transport.send_request(request: request_body)
|
||||||
|
|
||||||
|
# Guard with `is_a?(Hash)` because custom transports may return non-Hash values.
|
||||||
|
if response.is_a?(Hash) && response.key?("error")
|
||||||
|
error = response["error"]
|
||||||
|
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
|
||||||
|
end
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
def request_id
|
def request_id
|
||||||
SecureRandom.uuid
|
SecureRandom.uuid
|
||||||
end
|
end
|
||||||
|
|
||||||
class RequestHandlerError < StandardError
|
|
||||||
attr_reader :error_type, :original_error, :request
|
|
||||||
|
|
||||||
def initialize(message, request, error_type: :internal_error, original_error: nil)
|
|
||||||
super(message)
|
|
||||||
@request = request
|
|
||||||
@error_type = error_type
|
|
||||||
@original_error = original_error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -369,5 +369,92 @@ module MCP
|
||||||
client = Client.new(transport: transport)
|
client = Client.new(transport: transport)
|
||||||
client.call_tool(tool: tool, arguments: arguments)
|
client.call_tool(tool: tool, arguments: arguments)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_tools_raises_server_error_on_error_response
|
||||||
|
transport = mock
|
||||||
|
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
|
||||||
|
|
||||||
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
error = assert_raises(Client::ServerError) { client.tools }
|
||||||
|
assert_equal(-32_601, error.code)
|
||||||
|
assert_equal("Method not found", error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_resources_raises_server_error_on_error_response
|
||||||
|
transport = mock
|
||||||
|
mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } }
|
||||||
|
|
||||||
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
error = assert_raises(Client::ServerError) { client.resources }
|
||||||
|
assert_equal(-32_602, error.code)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_read_resource_raises_server_error_on_error_response
|
||||||
|
transport = mock
|
||||||
|
mock_response = { "error" => { "code" => -32_602, "message" => "Resource not found" } }
|
||||||
|
|
||||||
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_get_prompt_raises_server_error_on_error_response
|
||||||
|
transport = mock
|
||||||
|
mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } }
|
||||||
|
|
||||||
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
assert_raises(Client::ServerError) { client.get_prompt(name: "missing") }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_prompts_raises_server_error_on_error_response
|
||||||
|
transport = mock
|
||||||
|
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
|
||||||
|
|
||||||
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
assert_raises(Client::ServerError) { client.prompts }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_resource_templates_raises_server_error_on_error_response
|
||||||
|
transport = mock
|
||||||
|
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
|
||||||
|
|
||||||
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
assert_raises(Client::ServerError) { client.resource_templates }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_call_tool_raises_server_error_on_error_response
|
||||||
|
transport = mock
|
||||||
|
mock_response = { "error" => { "code" => -32_602, "message" => "Tool not found" } }
|
||||||
|
|
||||||
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
error = assert_raises(Client::ServerError) { client.call_tool(name: "missing") }
|
||||||
|
assert_equal(-32_602, error.code)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_server_error_includes_data_field
|
||||||
|
transport = mock
|
||||||
|
mock_response = {
|
||||||
|
"error" => { "code" => -32_603, "message" => "Internal error", "data" => "extra details" },
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
error = assert_raises(Client::ServerError) { client.tools }
|
||||||
|
assert_equal("extra details", error.data)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue