Tools

Tools are one of the core primitives in MCP. They allow an LLM to take actions on the user’s behalf, such as performing calculations, interacting with external APIs, or managing local files.

In mcp-cpp-sdk, tools are defined using the add_tool method on the Server class. Each tool requires:

  • Name: A unique identifier for the tool.

  • Description: A human-readable explanation of what the tool does.

  • Input Schema: A JSON Schema defining the input parameters.

  • Handler: A C++ function or lambda that executes the tool logic.

Basic Tool Registration

The simplest way to register a tool is with a synchronous handler that takes nlohmann::json and returns nlohmann::json. A returned object made only of CallToolResult members (content, structuredContent, isError, _meta) that decodes as one is sent as the result, as in the example below. Any other value is turned into a CallToolResult: a string becomes the text block, an object becomes structuredContent with its serialization as the text block, and anything else is serialized into the text block. An object whose only member is an empty content array is treated as data and wrapped.

#include <mcp/mcp.hpp>

nlohmann::json schema = {
    {"type", "object"},
    {"properties", {
        {"a", {{"type", "number"}}},
        {"b", {{"type", "number"}}}
    }},
    {"required", nlohmann::json::array({"a", "b"})}
};

server.add_tool("add", "Add two numbers", std::move(schema),
                [](const nlohmann::json& args) -> nlohmann::json {
                    double a = args.at("a").get<double>();
                    double b = args.at("b").get<double>();
                    std::string text = "Result: " + std::to_string(a + b);
                    return {{"content", nlohmann::json::array(
                        {nlohmann::json{{"type", "text"}, {"text", std::move(text)}}})}};
                });

Typed Handlers

For better type safety and cleaner code, you can use custom C++ structs for tool input and output. Any type that implements to_json and from_json (using NLOHMANN_DEFINE_TYPE_INTRUSIVE or manual implementation) can be used.

Using typed handlers provides several advantages:

  1. Automatic Validation: The SDK automatically deserializes the incoming JSON into your struct, validating the presence and types of required fields.

  2. Type Safety: Your handler logic works with native C++ types rather than dynamic JSON objects.

  3. Self-Documentation: The input and output structures serve as clear documentation of the tool’s interface.

The following example demonstrates defining input and output structures for a calculator tool:

struct AddInput {
    double a;
    double b;
};

inline void to_json(nlohmann::json& j, const AddInput& v) {
    j = nlohmann::json{{"a", v.a}, {"b", v.b}};
}

inline void from_json(const nlohmann::json& j, AddInput& v) {
    j.at("a").get_to(v.a);
    j.at("b").get_to(v.b);
}

struct AddOutput {
    double result;
};

inline void to_json(nlohmann::json& j, const AddOutput& v) { j = nlohmann::json{{"result", v.result}}; }

inline void from_json(const nlohmann::json& j, AddOutput& v) { j.at("result").get_to(v.result); }

You then register the tool by providing these types as template arguments to add_tool:

        // Tool: add — sync handler, no context
        nlohmann::json add_schema = {
            {"type", "object"},
            {"properties", {{"a", {{"type", "number"}}}, {"b", {{"type", "number"}}}}},
            {"required", nlohmann::json::array({"a", "b"})}};

        server.add_tool<AddInput, AddOutput>(
            "add", "Add two numbers", add_schema,
            [](AddInput in) -> AddOutput { return AddOutput{.result = in.a + in.b}; });

When using typed handlers, the SDK uses the template parameters to automatically wrap your handler in a layer that performs the conversion between nlohmann::json and your custom types.

Async Handlers with Context

Many tools need to perform asynchronous work, such as network requests or long-running computations. mcp-cpp-sdk supports coroutine-based handlers returning mcp::Task<T>.

Additionally, handlers can accept a mcp::Context& parameter to access server-level features:

  • Logging: Send real-time logs back to the client.

  • Progress Reporting: Inform the client about long-running task status.

  • Cancellation: Check if the client has requested to abort the task.

The following example shows a tool that logs information and performs an asynchronous operation:

        // Tool: log_and_upper — async handler, with context (demonstrates ctx.log_info)
        nlohmann::json upper_schema = {{"type", "object"},
                                       {"properties", {{"text", {{"type", "string"}}}}},
                                       {"required", nlohmann::json::array({"text"})}};

        server.add_tool<nlohmann::json, nlohmann::json>(
            "log_and_upper", "Uppercase the input and log via context", upper_schema,
            [](Context& ctx, nlohmann::json in) -> Task<nlohmann::json> {
                auto text = in.at("text").get<std::string>();
                co_await ctx.log_info("Uppercasing: " + text);
                for (auto& ch : text) {
                    ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
                }
                nlohmann::json out = {{"result", text}};
                co_return out;
            });

Progress and Cancellation

For tasks that take a significant amount of time, it is good practice to report progress and periodically check for cancellation requests.

            [](Context& ctx, const nlohmann::json& args) -> Task<nlohmann::json> {
                int duration_ms = args.at("duration_ms").get<int>();
                int steps = 10;
                int step_duration_ms = duration_ms / steps;

                std::cout << "Tool: Starting long_task for " << duration_ms << "ms\n";

                for (int i = 0; i < steps; ++i) {
                    if (ctx.is_cancelled()) {
                        std::cout << "Tool: Cancellation detected at step " << i << "\n";
                        co_return nlohmann::json{
                            {"content", nlohmann::json::array({nlohmann::json{
                                            {"type", "text"},
                                            {"text", "Task cancelled at step " + std::to_string(i) +
                                                         "/" + std::to_string(steps)}}})},
                            {"isError", true}};
                    }

                    auto executor = co_await asio::this_coro::executor;
                    asio::steady_timer work_timer(executor);
                    work_timer.expires_after(std::chrono::milliseconds(step_duration_ms));
                    co_await work_timer.async_wait(asio::use_awaitable);

                    int progress = (i + 1) * 100 / steps;
                    std::string message =
                        "Processing step " + std::to_string(i + 1) + "/" + std::to_string(steps);
                    co_await ctx.report_progress(progress, 100, message);
                    std::cout << "Tool: Reported progress " << progress << "%\n";
                }

                std::cout << "Tool: Completed all steps\n";
                co_return nlohmann::json{
                    {"content", nlohmann::json::array(
                                    {nlohmann::json{{"type", "text"},
                                                    {"text", "Task completed successfully with " +
                                                                 std::to_string(steps) + " steps"}}})},
                    {"isError", false}};
            });

        // ========== TRANSPORT & RUN ==========
        auto [client_transport, server_transport] =
            mcp::create_memory_transport_pair(io_ctx.get_executor());

For more details on using the context, see Server Context.

Error Handling

There are two primary ways to handle errors in tool handlers:

  1. Throwing Exceptions: A std::exception thrown by any tool handler (synchronous, typed or asynchronous) is converted into a tool error result (isError: true) whose text is the exception message. An exception from middleware is not: it is answered as a JSON-RPC -32603 error (see Middleware).

  2. Explicit Error Returns: A handler declared with Out=mcp::CallToolResult can return a complete result whose isError field is true. add_raw_tool() provides the equivalent low-level JSON escape hatch with runtime validation.

The following example demonstrates both patterns:

        // Tool 1: Throws std::runtime_error
        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);
                        });

        // Tool 2: Returns error JSON explicitly
        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;
            });

Use isError: true when the tool completed normally but needs to report an application-level failure back to the client. Either way the client receives a normal tools/call result and the session stays open. This is a critical distinction:

  • Protocol Errors: Issues like malformed JSON, invalid method names, or disconnected transports cause JSON-RPC level errors.

  • Application Errors: Issues within your tool’s logic (e.g., “file not found”, “invalid input value”) should usually be returned as isError: true tool results so the client can inspect them as normal tool output.

Tool Output Schema

In addition to the input schema, you can provide an optional outputSchema. This allows clients to understand the structure of the data returned by the tool, enabling better integration with typed systems or UI rendering.

When an outputSchema is provided, the SDK includes it in the tool definition sent to clients. The schema is advertised as outputSchema in the tool’s metadata, enabling clients to understand the structure of the tool’s output. A tool with an outputSchema must return structuredContent (unless the result is an error); otherwise the call fails with a JSON-RPC error. The SDK does not validate that content against the schema at runtime.

nlohmann::json output_schema = {
    {"type", "object"},
    {"properties", {
        {"result", {{"type", "number"}}}
    }}
};

server.add_tool<AddInput, AddOutput>(
    "add_with_output_schema",
    "Add two numbers with defined output structure",
    input_schema,
    output_schema,
    [](AddInput in) -> AddOutput { return {in.a + in.b}; }
);

Structured Content

While simple tools can return raw JSON, more complex tools might want to return structured content blocks (text, images, or embedded resources). The mcp::CallToolResult structure provides a standard way to return multiple content blocks.

        nlohmann::json success_schema = {{"type", "object"},
                                         {"properties", {{"value", {{"type", "integer"}}}}},
                                         {"required", nlohmann::json::array({"value"})}};

        server.add_tool("success", "Returns successfully", success_schema,
                        [](const nlohmann::json& args) -> nlohmann::json {
                            std::cout << "[Server] success handler called\n";
                            int value = args.at("value").get<int>();
                            mcp::CallToolResult result;
                            mcp::TextContent content;
                            content.text =
                                "Successfully doubled the value: " + std::to_string(value * 2);
                            result.content.emplace_back(std::move(content));
                            return nlohmann::json(result);
                        });

See Also