Skip to content

Commit 389cc2e

Browse files
committed
Fix tool error handling to follow MCP spec
Tool execution errors now return successful responses with isError: true instead of JSON-RPC protocol errors, per the MCP specification. This allows clients to distinguish between protocol-level errors and tool execution errors, providing better error context. Fixes #159
1 parent c510600 commit 389cc2e

File tree

3 files changed

+71
-39
lines changed

3 files changed

+71
-39
lines changed

lib/mcp/server.rb

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -258,37 +258,60 @@ def list_tools(request)
258258

259259
def call_tool(request)
260260
tool_name = request[:name]
261+
261262
tool = tools[tool_name]
262263
unless tool
263-
add_instrumentation_data(error: :tool_not_found)
264-
raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found)
264+
add_instrumentation_data(tool_name:, error: :tool_not_found)
265+
return Tool::Response.new(
266+
[{
267+
type: "text",
268+
text: "Tool not found: #{tool_name}",
269+
}],
270+
error: true,
271+
).to_h
265272
end
266273

267274
arguments = request[:arguments] || {}
268275
add_instrumentation_data(tool_name:)
269276

270277
if tool.input_schema&.missing_required_arguments?(arguments)
271278
add_instrumentation_data(error: :missing_required_arguments)
272-
raise RequestHandlerError.new(
273-
"Missing required arguments: #{tool.input_schema.missing_required_arguments(arguments).join(", ")}",
274-
request,
275-
error_type: :missing_required_arguments,
276-
)
279+
missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
280+
return Tool::Response.new(
281+
[{
282+
type: "text",
283+
text: "Missing required arguments: #{missing}",
284+
}],
285+
error: true,
286+
).to_h
277287
end
278288

279289
if configuration.validate_tool_call_arguments && tool.input_schema
280290
begin
281291
tool.input_schema.validate_arguments(arguments)
282292
rescue Tool::InputSchema::ValidationError => e
283293
add_instrumentation_data(error: :invalid_schema)
284-
raise RequestHandlerError.new(e.message, request, error_type: :invalid_schema)
294+
return Tool::Response.new(
295+
[{
296+
type: "text",
297+
text: e.message,
298+
}],
299+
error: true,
300+
).to_h
285301
end
286302
end
287303

288304
begin
289305
call_tool_with_args(tool, arguments)
290306
rescue => e
291-
raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e)
307+
report_exception(e, { request: request })
308+
Tool::Response.new(
309+
[{
310+
type: "text",
311+
text: "Internal error calling tool #{tool_name}: #{e.message}",
312+
}],
313+
error: true,
314+
).to_h
292315
end
293316
end
294317

test/mcp/server_context_test.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,10 @@ def call(message:, server_context:)
152152

153153
response = server_no_context.handle(request)
154154

155-
assert response[:error]
156-
# The error is wrapped as "Internal error calling tool..."
157-
assert_equal "Internal error", response[:error][:message]
155+
assert_nil response[:error], "Expected no JSON-RPC error"
156+
assert response[:result][:isError]
157+
assert_equal "text", response[:result][:content][0][:type]
158+
assert_match(/Internal error calling tool tool_with_required_context: /, response[:result][:content][0][:text])
158159
end
159160

160161
test "call_tool_with_args correctly detects server_context parameter presence" do

test/mcp/server_test.rb

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ class ServerTest < ActiveSupport::TestCase
255255
assert_instrumentation_data({ method: "tools/call", tool_name: })
256256
end
257257

258-
test "#handle tools/call returns error if required tool arguments are missing" do
258+
test "#handle tools/call returns error response with isError true if required tool arguments are missing" do
259259
tool_with_required_argument = Tool.define(
260260
name: "test_tool",
261261
title: "Test tool",
@@ -279,8 +279,10 @@ class ServerTest < ActiveSupport::TestCase
279279

280280
response = server.handle(request)
281281

282-
assert_equal "Missing required arguments: message", response[:error][:data]
283-
assert_equal "Internal error", response[:error][:message]
282+
assert_nil response[:error], "Expected no JSON-RPC error"
283+
assert response[:result][:isError]
284+
assert_equal "text", response[:result][:content][0][:type]
285+
assert_equal "Missing required arguments: message", response[:result][:content][0][:text]
284286
end
285287

286288
test "#handle_json tools/call executes tool and returns result" do
@@ -340,17 +342,12 @@ def call(message:, server_context: nil)
340342
assert_equal({ content: [{ type: "text", content: "OK" }], isError: false }, response[:result])
341343
end
342344

343-
test "#handle tools/call returns internal error and reports exception if the tool raises an error" do
345+
test "#handle tools/call returns error response with isError true if the tool raises an error" do
344346
@server.configuration.exception_reporter.expects(:call).with do |exception, server_context|
345347
assert_not_nil exception
346348
assert_equal(
347349
{
348-
request: {
349-
jsonrpc: "2.0",
350-
method: "tools/call",
351-
params: { name: "tool_that_raises", arguments: { message: "test" } },
352-
id: 1,
353-
},
350+
request: { name: "tool_that_raises", arguments: { message: "test" } },
354351
},
355352
server_context,
356353
)
@@ -368,12 +365,14 @@ def call(message:, server_context: nil)
368365

369366
response = @server.handle(request)
370367

371-
assert_equal "Internal error", response[:error][:message]
372-
assert_equal "Internal error calling tool tool_that_raises", response[:error][:data]
373-
assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", error: :internal_error })
368+
assert_nil response[:error], "Expected no JSON-RPC error"
369+
assert response[:result][:isError]
370+
assert_equal "text", response[:result][:content][0][:type]
371+
assert_match(/Internal error calling tool tool_that_raises: /, response[:result][:content][0][:text])
372+
assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises" })
374373
end
375374

376-
test "#handle_json returns internal error and reports exception if the tool raises an error" do
375+
test "#handle_json returns error response with isError true if the tool raises an error" do
377376
request = JSON.generate({
378377
jsonrpc: "2.0",
379378
method: "tools/call",
@@ -385,12 +384,14 @@ def call(message:, server_context: nil)
385384
})
386385

387386
response = JSON.parse(@server.handle_json(request), symbolize_names: true)
388-
assert_equal "Internal error", response[:error][:message]
389-
assert_equal "Internal error calling tool tool_that_raises", response[:error][:data]
390-
assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", error: :internal_error })
387+
assert_nil response[:error], "Expected no JSON-RPC error"
388+
assert response[:result][:isError]
389+
assert_equal "text", response[:result][:content][0][:type]
390+
assert_match(/Internal error calling tool tool_that_raises: /, response[:result][:content][0][:text])
391+
assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises" })
391392
end
392393

393-
test "#handle tools/call returns error for unknown tool" do
394+
test "#handle tools/call returns error response with isError true for unknown tool" do
394395
request = {
395396
jsonrpc: "2.0",
396397
method: "tools/call",
@@ -402,12 +403,14 @@ def call(message:, server_context: nil)
402403
}
403404

404405
response = @server.handle(request)
405-
assert_equal "Internal error", response[:error][:message]
406-
assert_equal "Tool not found unknown_tool", response[:error][:data]
407-
assert_instrumentation_data({ method: "tools/call", error: :tool_not_found })
406+
assert_nil response[:error], "Expected no JSON-RPC error"
407+
assert response[:result][:isError]
408+
assert_equal "text", response[:result][:content][0][:type]
409+
assert_equal "Tool not found: unknown_tool", response[:result][:content][0][:text]
410+
assert_instrumentation_data({ method: "tools/call", tool_name: "unknown_tool", error: :tool_not_found })
408411
end
409412

410-
test "#handle_json returns error for unknown tool" do
413+
test "#handle_json returns error response with isError true for unknown tool" do
411414
request = JSON.generate({
412415
jsonrpc: "2.0",
413416
method: "tools/call",
@@ -419,7 +422,10 @@ def call(message:, server_context: nil)
419422
})
420423

421424
response = JSON.parse(@server.handle_json(request), symbolize_names: true)
422-
assert_equal "Internal error", response[:error][:message]
425+
assert_nil response[:error], "Expected no JSON-RPC error"
426+
assert response[:result][:isError]
427+
assert_equal "text", response[:result][:content][0][:type]
428+
assert_equal "Tool not found: unknown_tool", response[:result][:content][0][:text]
423429
end
424430

425431
test "#tools_call_handler sets the tools/call handler" do
@@ -945,8 +951,9 @@ def call(message:, server_context: nil)
945951

946952
assert_equal "2.0", response[:jsonrpc]
947953
assert_equal 1, response[:id]
948-
assert_equal(-32603, response[:error][:code])
949-
assert_includes response[:error][:data], "Missing required arguments"
954+
assert_nil response[:error], "Expected no JSON-RPC error"
955+
assert response[:result][:isError]
956+
assert_includes response[:result][:content][0][:text], "Missing required arguments"
950957
end
951958

952959
test "tools/call validates arguments against input schema when validate_tool_call_arguments is true" do
@@ -969,8 +976,9 @@ def call(message:, server_context: nil)
969976

970977
assert_equal "2.0", response[:jsonrpc]
971978
assert_equal 1, response[:id]
972-
assert_equal(-32603, response[:error][:code])
973-
assert_includes response[:error][:data], "Invalid arguments"
979+
assert_nil response[:error], "Expected no JSON-RPC error"
980+
assert response[:result][:isError]
981+
assert_includes response[:result][:content][0][:text], "Invalid arguments"
974982
end
975983

976984
test "tools/call skips argument validation when validate_tool_call_arguments is false" do

0 commit comments

Comments
 (0)