Skip to content

Commit d1e7a83

Browse files
committed
add more robust error handling
1 parent d8e1caf commit d1e7a83

File tree

3 files changed

+330
-21
lines changed

3 files changed

+330
-21
lines changed

lib/model_context_protocol/client.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,16 @@
77
module ModelContextProtocol
88
module Client
99
# Can be made an abstract class if we need shared behavior
10+
11+
class RequestHandlerError < StandardError
12+
attr_reader :error_type, :original_error, :request
13+
14+
def initialize(message, request, error_type: :internal_error, original_error: nil)
15+
super(message)
16+
@request = request
17+
@error_type = error_type
18+
@original_error = original_error
19+
end
20+
end
1021
end
1122
end

lib/model_context_protocol/client/http.rb

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
# frozen_string_literal: true
22

3-
# require "json_rpc_handler"
4-
# require_relative "shared/instrumentation"
5-
# require_relative "shared/methods"
6-
73
module ModelContextProtocol
84
module Client
95
class Http
@@ -17,42 +13,86 @@ def initialize(url:, version: DEFAULT_VERSION)
1713
end
1814

1915
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
16+
response = make_request(method: "tools/list").body
2717

2818
::ModelContextProtocol::Client::Tools.new(response)
2919
end
3020

3121
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-
},
22+
response = make_request(
23+
method: "tools/call",
24+
params: { name: tool.name, arguments: input },
4125
).body
4226

4327
response.dig("result", "content", 0, "text")
4428
end
4529

4630
private
4731

32+
# TODO: support auth
4833
def client
4934
@client ||= Faraday.new(url) do |faraday|
5035
faraday.request(:json)
5136
faraday.response(:json)
52-
# TODO: error middleware?
37+
faraday.response(:raise_error)
5338
end
5439
end
5540

41+
def make_request(method:, params: nil)
42+
client.post(
43+
"",
44+
{
45+
jsonrpc: "2.0",
46+
id: request_id,
47+
method:,
48+
params:,
49+
mcp: { jsonrpc: "2.0", id: request_id, method:, params: }.compact,
50+
}.compact,
51+
)
52+
rescue Faraday::BadRequestError => e
53+
raise RequestHandlerError.new(
54+
"The #{method} request is invalid",
55+
{ method:, params: },
56+
error_type: :bad_request,
57+
original_error: e,
58+
)
59+
rescue Faraday::UnauthorizedError => e
60+
raise RequestHandlerError.new(
61+
"You are unauthorized to make #{method} requests",
62+
{ method:, params: },
63+
error_type: :unauthorized,
64+
original_error: e,
65+
)
66+
rescue Faraday::ForbiddenError => e
67+
raise RequestHandlerError.new(
68+
"You are forbidden to make #{method} requests",
69+
{ method:, params: },
70+
error_type: :forbidden,
71+
original_error: e,
72+
)
73+
rescue Faraday::ResourceNotFound => e
74+
raise RequestHandlerError.new(
75+
"The #{method} request is not found",
76+
{ method:, params: },
77+
error_type: :not_found,
78+
original_error: e,
79+
)
80+
rescue Faraday::UnprocessableEntityError => e
81+
raise RequestHandlerError.new(
82+
"The #{method} request is unprocessable",
83+
{ method:, params: },
84+
error_type: :unprocessable_entity,
85+
original_error: e,
86+
)
87+
rescue Faraday::Error => e # Catch-all
88+
raise RequestHandlerError.new(
89+
"Internal error handling #{method} request",
90+
{ method:, params: },
91+
error_type: :internal_error,
92+
original_error: e,
93+
)
94+
end
95+
5696
def request_id
5797
SecureRandom.uuid_v7
5898
end

test/model_context_protocol/client/http_test.rb

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,264 @@ def test_call_tool_handles_empty_response
160160
assert_nil(response)
161161
end
162162

163+
def test_raises_bad_request_error
164+
tool = Tool.new(
165+
"name" => "test_tool",
166+
"description" => "A test tool",
167+
"inputSchema" => {
168+
"type" => "object",
169+
"properties" => {},
170+
},
171+
)
172+
input = { "param" => "value" }
173+
174+
stub_request(:post, url)
175+
.with(
176+
body: {
177+
jsonrpc: "2.0",
178+
id: mock_request_id,
179+
method: "tools/call",
180+
params: {
181+
name: "test_tool",
182+
arguments: input,
183+
},
184+
mcp: {
185+
jsonrpc: "2.0",
186+
id: mock_request_id,
187+
method: "tools/call",
188+
params: {
189+
name: "test_tool",
190+
arguments: input,
191+
},
192+
},
193+
},
194+
)
195+
.to_return(status: 400)
196+
197+
error = assert_raises(RequestHandlerError) do
198+
client.call_tool(tool: tool, input: input)
199+
end
200+
201+
assert_equal("The tools/call request is invalid", error.message)
202+
assert_equal(:bad_request, error.error_type)
203+
assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request)
204+
end
205+
206+
def test_raises_unauthorized_error
207+
tool = Tool.new(
208+
"name" => "test_tool",
209+
"description" => "A test tool",
210+
"inputSchema" => {
211+
"type" => "object",
212+
"properties" => {},
213+
},
214+
)
215+
input = { "param" => "value" }
216+
217+
stub_request(:post, url)
218+
.with(
219+
body: {
220+
jsonrpc: "2.0",
221+
id: mock_request_id,
222+
method: "tools/call",
223+
params: {
224+
name: "test_tool",
225+
arguments: input,
226+
},
227+
mcp: {
228+
jsonrpc: "2.0",
229+
id: mock_request_id,
230+
method: "tools/call",
231+
params: {
232+
name: "test_tool",
233+
arguments: input,
234+
},
235+
},
236+
},
237+
)
238+
.to_return(status: 401)
239+
240+
error = assert_raises(RequestHandlerError) do
241+
client.call_tool(tool: tool, input: input)
242+
end
243+
244+
assert_equal("You are unauthorized to make tools/call requests", error.message)
245+
assert_equal(:unauthorized, error.error_type)
246+
assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request)
247+
end
248+
249+
def test_raises_forbidden_error
250+
tool = Tool.new(
251+
"name" => "test_tool",
252+
"description" => "A test tool",
253+
"inputSchema" => {
254+
"type" => "object",
255+
"properties" => {},
256+
},
257+
)
258+
input = { "param" => "value" }
259+
260+
stub_request(:post, url)
261+
.with(
262+
body: {
263+
jsonrpc: "2.0",
264+
id: mock_request_id,
265+
method: "tools/call",
266+
params: {
267+
name: "test_tool",
268+
arguments: input,
269+
},
270+
mcp: {
271+
jsonrpc: "2.0",
272+
id: mock_request_id,
273+
method: "tools/call",
274+
params: {
275+
name: "test_tool",
276+
arguments: input,
277+
},
278+
},
279+
},
280+
)
281+
.to_return(status: 403)
282+
283+
error = assert_raises(RequestHandlerError) do
284+
client.call_tool(tool: tool, input: input)
285+
end
286+
287+
assert_equal("You are forbidden to make tools/call requests", error.message)
288+
assert_equal(:forbidden, error.error_type)
289+
assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request)
290+
end
291+
292+
def test_raises_not_found_error
293+
tool = Tool.new(
294+
"name" => "test_tool",
295+
"description" => "A test tool",
296+
"inputSchema" => {
297+
"type" => "object",
298+
"properties" => {},
299+
},
300+
)
301+
input = { "param" => "value" }
302+
303+
stub_request(:post, url)
304+
.with(
305+
body: {
306+
jsonrpc: "2.0",
307+
id: mock_request_id,
308+
method: "tools/call",
309+
params: {
310+
name: "test_tool",
311+
arguments: input,
312+
},
313+
mcp: {
314+
jsonrpc: "2.0",
315+
id: mock_request_id,
316+
method: "tools/call",
317+
params: {
318+
name: "test_tool",
319+
arguments: input,
320+
},
321+
},
322+
},
323+
)
324+
.to_return(status: 404)
325+
326+
error = assert_raises(RequestHandlerError) do
327+
client.call_tool(tool: tool, input: input)
328+
end
329+
330+
assert_equal("The tools/call request is not found", error.message)
331+
assert_equal(:not_found, error.error_type)
332+
assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request)
333+
end
334+
335+
def test_raises_unprocessable_entity_error
336+
tool = Tool.new(
337+
"name" => "test_tool",
338+
"description" => "A test tool",
339+
"inputSchema" => {
340+
"type" => "object",
341+
"properties" => {},
342+
},
343+
)
344+
input = { "param" => "value" }
345+
346+
stub_request(:post, url)
347+
.with(
348+
body: {
349+
jsonrpc: "2.0",
350+
id: mock_request_id,
351+
method: "tools/call",
352+
params: {
353+
name: "test_tool",
354+
arguments: input,
355+
},
356+
mcp: {
357+
jsonrpc: "2.0",
358+
id: mock_request_id,
359+
method: "tools/call",
360+
params: {
361+
name: "test_tool",
362+
arguments: input,
363+
},
364+
},
365+
},
366+
)
367+
.to_return(status: 422)
368+
369+
error = assert_raises(RequestHandlerError) do
370+
client.call_tool(tool: tool, input: input)
371+
end
372+
373+
assert_equal("The tools/call request is unprocessable", error.message)
374+
assert_equal(:unprocessable_entity, error.error_type)
375+
assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request)
376+
end
377+
378+
def test_raises_internal_error
379+
tool = Tool.new(
380+
"name" => "test_tool",
381+
"description" => "A test tool",
382+
"inputSchema" => {
383+
"type" => "object",
384+
"properties" => {},
385+
},
386+
)
387+
input = { "param" => "value" }
388+
389+
stub_request(:post, url)
390+
.with(
391+
body: {
392+
jsonrpc: "2.0",
393+
id: mock_request_id,
394+
method: "tools/call",
395+
params: {
396+
name: "test_tool",
397+
arguments: input,
398+
},
399+
mcp: {
400+
jsonrpc: "2.0",
401+
id: mock_request_id,
402+
method: "tools/call",
403+
params: {
404+
name: "test_tool",
405+
arguments: input,
406+
},
407+
},
408+
},
409+
)
410+
.to_return(status: 500)
411+
412+
error = assert_raises(RequestHandlerError) do
413+
client.call_tool(tool: tool, input: input)
414+
end
415+
416+
assert_equal("Internal error handling tools/call request", error.message)
417+
assert_equal(:internal_error, error.error_type)
418+
assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request)
419+
end
420+
163421
private
164422

165423
def stub_request(method, url)

0 commit comments

Comments
 (0)