Skip to content

Commit 11f2e05

Browse files
authored
Merge pull request #281 from koic/client_raise_on_error_response
Fix client methods silently swallowing JSON-RPC error responses
2 parents eacf51e + 590feb1 commit 11f2e05

File tree

2 files changed

+133
-48
lines changed

2 files changed

+133
-48
lines changed

lib/mcp/client.rb

Lines changed: 46 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@
66

77
module MCP
88
class Client
9+
class ServerError < StandardError
10+
attr_reader :code, :data
11+
12+
def initialize(message, code:, data: nil)
13+
super(message)
14+
@code = code
15+
@data = data
16+
end
17+
end
18+
19+
class RequestHandlerError < StandardError
20+
attr_reader :error_type, :original_error, :request
21+
22+
def initialize(message, request, error_type: :internal_error, original_error: nil)
23+
super(message)
24+
@request = request
25+
@error_type = error_type
26+
@original_error = original_error
27+
end
28+
end
29+
930
# Initializes a new MCP::Client instance.
1031
#
1132
# @param transport [Object] The transport object to use for communication with the server.
@@ -33,11 +54,7 @@ def initialize(transport:)
3354
# puts tool.name
3455
# end
3556
def tools
36-
response = transport.send_request(request: {
37-
jsonrpc: JsonRpcHandler::Version::V2_0,
38-
id: request_id,
39-
method: "tools/list",
40-
})
57+
response = request(method: "tools/list")
4158

4259
response.dig("result", "tools")&.map do |tool|
4360
Tool.new(
@@ -53,11 +70,7 @@ def tools
5370
#
5471
# @return [Array<Hash>] An array of available resources.
5572
def resources
56-
response = transport.send_request(request: {
57-
jsonrpc: JsonRpcHandler::Version::V2_0,
58-
id: request_id,
59-
method: "resources/list",
60-
})
73+
response = request(method: "resources/list")
6174

6275
response.dig("result", "resources") || []
6376
end
@@ -67,11 +80,7 @@ def resources
6780
#
6881
# @return [Array<Hash>] An array of available resource templates.
6982
def resource_templates
70-
response = transport.send_request(request: {
71-
jsonrpc: JsonRpcHandler::Version::V2_0,
72-
id: request_id,
73-
method: "resources/templates/list",
74-
})
83+
response = request(method: "resources/templates/list")
7584

7685
response.dig("result", "resourceTemplates") || []
7786
end
@@ -81,11 +90,7 @@ def resource_templates
8190
#
8291
# @return [Array<Hash>] An array of available prompts.
8392
def prompts
84-
response = transport.send_request(request: {
85-
jsonrpc: JsonRpcHandler::Version::V2_0,
86-
id: request_id,
87-
method: "prompts/list",
88-
})
93+
response = request(method: "prompts/list")
8994

9095
response.dig("result", "prompts") || []
9196
end
@@ -119,25 +124,15 @@ def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil)
119124
params[:_meta] = { progressToken: progress_token }
120125
end
121126

122-
transport.send_request(request: {
123-
jsonrpc: JsonRpcHandler::Version::V2_0,
124-
id: request_id,
125-
method: "tools/call",
126-
params: params,
127-
})
127+
request(method: "tools/call", params: params)
128128
end
129129

130130
# Reads a resource from the server by URI and returns the contents.
131131
#
132132
# @param uri [String] The URI of the resource to read.
133133
# @return [Array<Hash>] An array of resource contents (text or blob).
134134
def read_resource(uri:)
135-
response = transport.send_request(request: {
136-
jsonrpc: JsonRpcHandler::Version::V2_0,
137-
id: request_id,
138-
method: "resources/read",
139-
params: { uri: uri },
140-
})
135+
response = request(method: "resources/read", params: { uri: uri })
141136

142137
response.dig("result", "contents") || []
143138
end
@@ -147,31 +142,34 @@ def read_resource(uri:)
147142
# @param name [String] The name of the prompt to get.
148143
# @return [Hash] A hash containing the prompt details.
149144
def get_prompt(name:)
150-
response = transport.send_request(request: {
151-
jsonrpc: JsonRpcHandler::Version::V2_0,
152-
id: request_id,
153-
method: "prompts/get",
154-
params: { name: name },
155-
})
145+
response = request(method: "prompts/get", params: { name: name })
156146

157147
response.fetch("result", {})
158148
end
159149

160150
private
161151

162-
def request_id
163-
SecureRandom.uuid
164-
end
152+
def request(method:, params: nil)
153+
request_body = {
154+
jsonrpc: JsonRpcHandler::Version::V2_0,
155+
id: request_id,
156+
method: method,
157+
}
158+
request_body[:params] = params if params
165159

166-
class RequestHandlerError < StandardError
167-
attr_reader :error_type, :original_error, :request
160+
response = transport.send_request(request: request_body)
168161

169-
def initialize(message, request, error_type: :internal_error, original_error: nil)
170-
super(message)
171-
@request = request
172-
@error_type = error_type
173-
@original_error = original_error
162+
# Guard with `is_a?(Hash)` because custom transports may return non-Hash values.
163+
if response.is_a?(Hash) && response.key?("error")
164+
error = response["error"]
165+
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
174166
end
167+
168+
response
169+
end
170+
171+
def request_id
172+
SecureRandom.uuid
175173
end
176174
end
177175
end

test/mcp/client_test.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,5 +369,92 @@ def test_call_tool_omits_meta_when_no_progress_token
369369
client = Client.new(transport: transport)
370370
client.call_tool(tool: tool, arguments: arguments)
371371
end
372+
373+
def test_tools_raises_server_error_on_error_response
374+
transport = mock
375+
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
376+
377+
transport.expects(:send_request).returns(mock_response).once
378+
379+
client = Client.new(transport: transport)
380+
error = assert_raises(Client::ServerError) { client.tools }
381+
assert_equal(-32_601, error.code)
382+
assert_equal("Method not found", error.message)
383+
end
384+
385+
def test_resources_raises_server_error_on_error_response
386+
transport = mock
387+
mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } }
388+
389+
transport.expects(:send_request).returns(mock_response).once
390+
391+
client = Client.new(transport: transport)
392+
error = assert_raises(Client::ServerError) { client.resources }
393+
assert_equal(-32_602, error.code)
394+
end
395+
396+
def test_read_resource_raises_server_error_on_error_response
397+
transport = mock
398+
mock_response = { "error" => { "code" => -32_602, "message" => "Resource not found" } }
399+
400+
transport.expects(:send_request).returns(mock_response).once
401+
402+
client = Client.new(transport: transport)
403+
assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") }
404+
end
405+
406+
def test_get_prompt_raises_server_error_on_error_response
407+
transport = mock
408+
mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } }
409+
410+
transport.expects(:send_request).returns(mock_response).once
411+
412+
client = Client.new(transport: transport)
413+
assert_raises(Client::ServerError) { client.get_prompt(name: "missing") }
414+
end
415+
416+
def test_prompts_raises_server_error_on_error_response
417+
transport = mock
418+
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
419+
420+
transport.expects(:send_request).returns(mock_response).once
421+
422+
client = Client.new(transport: transport)
423+
assert_raises(Client::ServerError) { client.prompts }
424+
end
425+
426+
def test_resource_templates_raises_server_error_on_error_response
427+
transport = mock
428+
mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } }
429+
430+
transport.expects(:send_request).returns(mock_response).once
431+
432+
client = Client.new(transport: transport)
433+
assert_raises(Client::ServerError) { client.resource_templates }
434+
end
435+
436+
def test_call_tool_raises_server_error_on_error_response
437+
transport = mock
438+
mock_response = { "error" => { "code" => -32_602, "message" => "Tool not found" } }
439+
440+
transport.expects(:send_request).returns(mock_response).once
441+
442+
client = Client.new(transport: transport)
443+
error = assert_raises(Client::ServerError) { client.call_tool(name: "missing") }
444+
assert_equal(-32_602, error.code)
445+
end
446+
447+
def test_server_error_includes_data_field
448+
transport = mock
449+
mock_response = {
450+
"error" => { "code" => -32_603, "message" => "Internal error", "data" => "extra details" },
451+
}
452+
453+
transport.expects(:send_request).returns(mock_response).once
454+
455+
client = Client.new(transport: transport)
456+
error = assert_raises(Client::ServerError) { client.tools }
457+
assert_equal("extra details", error.data)
458+
end
372459
end
373460
end

0 commit comments

Comments
 (0)