Error Handling
This guide covers error handling patterns in the MCP C++ SDK, focusing on both server-side and client-side strategies. Proper error handling ensures that your MCP applications are resilient, informative, and provide a smooth user experience even when things go wrong.
Overview
The MCP C++ SDK leverages a combination of JSON-RPC error codes, C++ exceptions, and structured result flags to communicate errors between servers and clients. The primary goals of the error handling system are:
Clarity: Provide meaningful error messages and codes to identify the source of the problem.
Isolation: Prevent a single failure from crashing the entire server or client session.
Graceful Degradation: Allow clients to handle errors and continue functioning where possible.
JSON-RPC Error Codes
The SDK follows the JSON-RPC 2.0 specification for error reporting. Standard error codes include:
-32700: Parse error (Invalid JSON received)-32600: Invalid Request-32601: Method not found-32602: Invalid params-32603: Internal error
Additionally, the SDK defines MCP-specific codes used throughout the library:
-32000: Unauthorized-32001: Request timed out-32800: Request cancelled
When implementing custom tools, you should prefer using standard error codes where they apply, or use codes in the range -32000 to -32099 for application-specific protocol errors.
Server-side Error Handling
Server handlers (tools, resources, prompts) can report errors in two primary ways:
Throwing Exceptions: Exceptions from a tool handler — typed, asynchronous or raw — are converted into tool results with
isError: true, because a tool that cannot complete has failed at its own task rather than at the protocol. Exceptions from resource and prompt handlers, which have no such result channel, propagate as JSON-RPCInternal error (-32603)responses, and so do exceptions from middleware: middleware decides whether a call may proceed at all, so refusing one is a protocol-level answer rather than a tool outcome. Exception messages are flattened and length-bounded before they reach the peer.Returning Error Results: For application-level errors (e.g., “File not found” or “Invalid input”), handlers can return a
CallToolResultwith theisErrorflag set totrue. This allows the client to distinguish between a technical failure (like a crash or timeout) and a logical error within the tool’s execution.
Choosing Between Exceptions and Error Results
Use Exceptions for: - Unexpected system failures (e.g., memory allocation failure, disk I/O errors). - Programming errors (e.g., out-of-bounds access, logic errors). - Conditions that prevent the handler from fulfilling its contract at all.
Use Error Results for: - Expected failure modes (e.g., user not found, validation errors). - Situations where the tool successfully ran but couldn’t produce the desired output. - Providing detailed, structured error information back to the LLM.
Example: Throwing an Exception
nlohmann::json throw_error_schema = {{"type", "object"},
{"properties", {{"message", {{"type", "string"}}}}},
{"required", nlohmann::json::array({"message"})}};
server.add_tool("throw_error", "Throws a runtime error", throw_error_schema,
[](const nlohmann::json& args) -> nlohmann::json {
std::cout << "[Server] throw_error handler called\n";
std::string msg = args.at("message").get<std::string>();
throw std::runtime_error("Intentional error: " + msg);
});
Example: Returning an Error Result
nlohmann::json return_error_schema = {{"type", "object"},
{"properties", {{"code", {{"type", "integer"}}}}},
{"required", nlohmann::json::array({"code"})}};
server.add_tool<nlohmann::json, mcp::CallToolResult>(
"return_error", "Returns error JSON", return_error_schema,
[](const nlohmann::json& args) -> mcp::CallToolResult {
std::cout << "[Server] return_error handler called\n";
int code = args.at("code").get<int>();
mcp::CallToolResult result;
mcp::TextContent content;
content.text = "Application error with code " + std::to_string(code);
result.content.emplace_back(std::move(content));
result.isError = true;
return result;
});
Implicit Exceptions
It’s important to be aware that some library calls might throw exceptions that you don’t explicitly catch. For instance, nlohmann::json throws an exception if you try to access a non-existent key using at(). In a tool handler such an exception becomes a tool error result like any other; in a resource or prompt handler it becomes a JSON-RPC internal error.
nlohmann::json invalid_access_schema = {
{"type", "object"},
{"properties", {{"optional_field", {{"type", "string"}}}}},
{"required", nlohmann::json::array({})}};
server.add_tool<nlohmann::json, mcp::CallToolResult>(
"invalid_access", "Accesses non-existent argument", invalid_access_schema,
[](const nlohmann::json& args) -> mcp::CallToolResult {
std::cout << "[Server] invalid_access handler called\n";
std::string value = args.at("required_field").get<std::string>();
mcp::CallToolResult result;
mcp::TextContent content;
content.text = value;
result.content.emplace_back(std::move(content));
return result;
});
Client-side Error Handling
Clients should always wrap server calls in try-catch blocks to handle potential network issues, protocol violations, or server-side errors.
std::cout << "=== TEST 1: Catch exception from thrown error ===\n";
try {
std::cout << "[Client] Calling throw_error tool...\n";
nlohmann::json args1 = {{"message", "test error"}};
auto result1 = co_await client.call_tool("throw_error", args1);
std::cout << "[Client] Server handled error as result (isError=true): "
<< result1.content.size() << " content blocks\n";
} catch (const std::exception& e) {
std::cout << "[Client] Caught exception: " << e.what() << "\n";
std::cout << "[Client] Server continues running after error\n";
}
std::cout << "\n";
Request Timeouts
Important
Every client request carries a 30-second deadline by default. Nothing
opts into it: ClientOptions::request_timeout starts at
std::chrono::seconds(30), and a default-constructed Client uses it
for call_tool, read_resource, list_tools and every other
request. A tool that legitimately runs longer than that — a large model
call, a slow build, a batch job — fails on the client side while the server
is still working on it.
Raise it for the whole client by passing ClientOptions to the constructor:
mcp::ClientOptions options;
options.request_timeout = std::chrono::minutes(5);
mcp::Client client(transport, io.get_executor(), options);
A single request can be given its own deadline through RequestOptions, but
only on the untyped send_request overload. The typed helpers —
call_tool, read_resource, list_tools and the rest — take no
per-request options and always use the client-wide default, so a client that
makes one slow call among many fast ones either raises the default for all of
them or drops to send_request for that one:
mcp::CallToolParams call_params;
call_params.name = "render";
call_params.arguments = args;
mcp::RequestOptions slow;
slow.timeout = std::chrono::minutes(30);
auto raw = co_await client.send_request(
"tools/call", nlohmann::json(call_params), slow);
auto result = raw.get<mcp::CallToolResult>();
When the deadline expires the awaiting coroutine throws
mcp::McpError with code -32001
(mcp::g_REQUEST_TIMEOUT) and the message Request timed out.
Catch it the same way as any other McpError.
Warning
A timeout is a local decision, not a cancellation. A request whose bytes have not been written yet is dropped before transmission, but once transport I/O has started the server may still run the call to completion after the client has given up. Treat a timed-out non-idempotent request as having an unknown outcome, and see the retry guidance below before repeating it.
A non-positive timeout is rejected rather than treated as “no deadline”:
constructing a Client with one, or passing one in RequestOptions,
throws std::invalid_argument. The SDK offers no way to disable the deadline
entirely — use a duration long enough for the slowest call you expect.
Catching Server Errors
When a server returns a JSON-RPC error response, the Client methods (like call_tool or read_resource) throw mcp::McpError, a std::runtime_error that carries the error code and message. This simplifies the client logic, as you can use standard C++ exception handling for both local and remote failures.
Handling Application Errors
If the server returns a successful JSON-RPC response but the tool itself failed (indicated by isError: true), the client receives the result normally and must check the flag. This is common when the tool executes correctly but encounters a business logic error that the LLM should be aware of.
std::cout << "=== TEST 2: Handle error JSON response ===\n";
try {
std::cout << "[Client] Calling return_error tool...\n";
nlohmann::json args2 = {{"code", 42}};
auto result2 = co_await client.call_tool("return_error", args2);
std::cout << "[Client] Tool returned (may contain error flag)\n";
if (result2.isError && *result2.isError) {
std::cout << "[Client] Response has isError=true\n";
}
std::cout << "[Client] Content blocks: " << result2.content.size() << "\n";
} catch (const std::exception& e) {
std::cout << "[Client] Caught exception: " << e.what() << "\n";
}
std::cout << "\n";
Graceful Degradation Patterns
To ensure your application remains usable during partial failures, consider the following patterns:
Feature Toggling: If a specific tool call fails consistently, your client application can temporarily disable or hide the functionality associated with that tool.
Fallback Content: When reading a resource fails, provide sensible default content or previously cached values. This is particularly useful for configuration or preference resources.
UI Feedback: Instead of showing a generic “Server Error,” use the detailed error message from the server to inform the user about what went wrong (e.g., “The search service is currently down, please try again later”).
Partial Results: In operations that return lists or multiple pieces of data, consider returning the successfully retrieved parts even if some encountered errors.
Retry Strategies
For transient errors, such as network timeouts or temporary server busy states (indicated by specific error codes), implementing a retry strategy is highly beneficial.
Exponential Backoff: Increase the wait time between retries (e.g., 100ms, 200ms, 400ms) to avoid overwhelming a struggling server.
Max Retries: Always define a maximum number of attempts to avoid infinite loops.
Idempotency: Only retry operations that are idempotent (safe to repeat), such as reading resources or pings. Avoid retrying tools that perform side effects (like making a purchase or deleting a file) unless you are certain it’s safe.
Logging and Monitoring
The SDK provides a built-in logging system that is essential for debugging and monitoring production systems. Use the Context object in your handlers to log errors and warnings.
server.add_tool<nlohmann::json, nlohmann::json>("example", "...", schema,
[](mcp::Context& ctx, nlohmann::json args) -> mcp::Task<nlohmann::json> {
try {
// ... work ...
} catch (const std::exception& e) {
co_await ctx.log(mcp::LoggingLevel::eError,
"Tool failed: " + std::string(e.what()));
throw;
}
});
By logging at the server level, you can aggregate errors across multiple clients and identify systemic issues. For more details on logging, see the Server Context guide.
Example Walkthrough
The examples/features/error_handling.cpp file provides a complete, runnable example of these patterns. It demonstrates:
A server with multiple tools that fail in different ways.
A client that catches these failures and continues to function.
Successful recovery where a subsequent tool call succeeds after previous failures.
Run the example to see the output:
./build/release/example-feature-error-handling