Middleware

Middleware provides a powerful way to intercept and augment the execution of tool handlers, resource requests, and prompt retrievals. By using middleware, you can implement cross-cutting concerns like logging, authentication, rate limiting, and request validation without cluttering your core business logic.

Overview

Middleware functions sit between the server’s request dispatcher and the final handler. They can:

  • Inspect and modify incoming request parameters.

  • Perform operations before and after the handler executes.

  • Short-circuit the request by returning a result, or refuse it by throwing, before the handler is even called.

  • Catch exceptions thrown by inner middleware and by resource and prompt handlers.

  • Inject additional context or metadata.

Registering Middleware

Middleware is registered using the server.use() method. A middleware function must match the mcp::Middleware signature:

using Middleware = std::function<Task<nlohmann::json>(
    Context& ctx,
    const nlohmann::json& params,
    TypeErasedHandler next
)>;

Where:

  • ctx: The request context (provides logging, progress, etc.).

  • params: The raw JSON parameters of the request.

  • next: A functional object representing the next step in the chain (either another middleware or the final handler).

The following example shows how to register multiple middleware layers:

        // Middleware A: Outer middleware (registered first)
        // Logs before and after the next middleware/handler
        server.use([](Context& ctx, const nlohmann::json& params,
                      TypeErasedHandler next) -> Task<nlohmann::json> {
            std::cout << "[Middleware A] Before handler\n";
            co_await ctx.log_info("Middleware A: before");

            auto result = co_await next(ctx, params);

            std::cout << "[Middleware A] After handler\n";
            co_await ctx.log_info("Middleware A: after");
            co_return result;
        });

        // Middleware B: Middle middleware (registered second)
        // Logs before and after, and can inspect params
        server.use([](Context& ctx, const nlohmann::json& params,
                      TypeErasedHandler next) -> Task<nlohmann::json> {
            std::cout << "[Middleware B] Before handler\n";
            co_await ctx.log_info("Middleware B: before");

            auto result = co_await next(ctx, params);

            std::cout << "[Middleware B] After handler\n";
            co_await ctx.log_info("Middleware B: after");
            co_return result;
        });

        // Middleware C: Inner middleware (registered third)
        // This middleware can short-circuit by checking params
        server.use([](Context& ctx, const nlohmann::json& params,
                      TypeErasedHandler next) -> Task<nlohmann::json> {
            std::cout << "[Middleware C] Before handler\n";
            co_await ctx.log_info("Middleware C: before");

            // Check if params contain a "block" flag to demonstrate short-circuiting
            if (params.contains("arguments") && params.at("arguments").contains("block") &&
                params.at("arguments").at("block").is_boolean() &&
                params.at("arguments").at("block").get<bool>()) {
                std::cout << "[Middleware C] Short-circuiting: block flag detected\n";
                co_await ctx.log_info("Middleware C: short-circuit");
                // A throw from middleware is answered as a JSON-RPC error (-32603), which
                // call_tool() raises as McpError.
                throw std::runtime_error("Request blocked by middleware C");
            }

            auto result = co_await next(ctx, params);

            std::cout << "[Middleware C] After handler\n";
            co_await ctx.log_info("Middleware C: after");
            co_return result;
        });

Execution Order

Middleware follows an “onion” model. The first middleware registered with server.use() becomes the outermost layer. It is the first to execute on the way “in” and the last to execute on the way “out”.

For example, if you register Middleware A then Middleware B:

  1. A starts.

  2. A calls next(), which triggers B.

  3. B starts.

  4. B calls next(), which triggers the actual Handler.

  5. Handler executes and returns a result to B.

  6. B finishes and returns a result to A.

  7. A finishes and returns the final result to the client.

Short-circuiting

A middleware can choose not to call next(). This is known as short-circuiting. It is useful for security checks, caching, or validation where you want to return an error or a cached response without involving the final handler.

When a middleware short-circuits, the execution chain is halted. Control immediately passes back to the previous middleware in the chain (if any) or back to the server’s dispatcher.

In the example below, Middleware C checks for a “block” flag and throws if it’s present, so the client receives a JSON-RPC Internal error (-32603) carrying the exception’s message:

        // This middleware can short-circuit by checking params
        server.use([](Context& ctx, const nlohmann::json& params,
                      TypeErasedHandler next) -> Task<nlohmann::json> {
            std::cout << "[Middleware C] Before handler\n";
            co_await ctx.log_info("Middleware C: before");

            // Check if params contain a "block" flag to demonstrate short-circuiting
            if (params.contains("arguments") && params.at("arguments").contains("block") &&
                params.at("arguments").at("block").is_boolean() &&
                params.at("arguments").at("block").get<bool>()) {
                std::cout << "[Middleware C] Short-circuiting: block flag detected\n";
                co_await ctx.log_info("Middleware C: short-circuit");
                // A throw from middleware is answered as a JSON-RPC error (-32603), which
                // call_tool() raises as McpError.
                throw std::runtime_error("Request blocked by middleware C");
            }

            auto result = co_await next(ctx, params);

            std::cout << "[Middleware C] After handler\n";
            co_await ctx.log_info("Middleware C: after");
            co_return result;
        });

Composition Patterns

Middleware can be composed to build complex request processing pipelines. By combining several small, single-purpose middleware functions, you can create highly specialized server behaviors while keeping the code maintainable.

Common patterns include:

  • Logging & Metrics: An outer middleware that logs the start and end of every request, measuring execution time. This provides visibility into server performance and usage patterns.

  • Authentication: A middleware that validates API keys, session tokens, or OAuth tokens before allowing access to sensitive tools. This ensures that only authorized clients can trigger certain actions.

  • Rate Limiting: A middleware that tracks request frequency from clients and returns a “Too Many Requests” error if they exceed their quota.

  • Validation: A middleware that ensures request parameters conform to additional business rules beyond what JSON Schema can express (e.g., checking if a file exists or if a value is within a dynamic range).

  • Error Mapping: A middleware that catches exceptions from resource and prompt handlers, or from inner middleware, and rethrows them with a clearer message. A tool handler’s exception never reaches middleware; it arrives as an isError result.

  • Tracing: A middleware that injects tracing headers or spans into the context to enable distributed tracing across multiple services.

Common Examples

Authentication

You can create middleware that checks a specific parameter. For the current OAuth and HTTP bearer-authentication building blocks, limits, and recommended security boundary, see OAuth.

server.use([](Context& ctx, const nlohmann::json& params, TypeErasedHandler next) -> Task<nlohmann::json> {
    if (!is_authorized(params)) {
        throw std::runtime_error("Unauthorized access");
    }
    co_return co_await next(ctx, params);
});

An exception thrown by middleware is answered as a JSON-RPC Internal error (-32603) for every request kind, tools/call included: middleware decides whether a call may proceed at all, which is a protocol-level answer rather than a tool outcome.

Throw to refuse a request. Middleware registered with server.use() wraps tool calls, resource reads and prompt requests alike, and Context does not say which kind it is handling. A CallToolResult returned without calling next(), such as make_tool_error_result(...), is passed through unchanged, but only tools/call expects that shape: a resources/read or prompts/get would send it to the client as a successful result. The bundled make_auth_middleware() answers a refused tools/call with a tool error result and a refused resources/read or prompts/get with -32603; it tells the two apart through a type internal to the SDK, which custom middleware cannot use.

Middleware runs outside the tool handler’s exception guard, so a tool handler’s exception never reaches it: by the time next() returns, the exception has already become a CallToolResult with isError: true. Middleware that counts or logs tool failures should inspect that flag rather than catch.

Logging

Middleware can use the Context to send logs back to the client during request execution.

server.use([](Context& ctx, const nlohmann::json& params, TypeErasedHandler next) -> Task<nlohmann::json> {
    co_await ctx.log_info("Request started");
    auto result = co_await next(ctx, params);
    co_await ctx.log_info("Request finished");
    co_return result;
});

Best Practices

  • Keep it focused: Each middleware should handle exactly one concern. This makes your code easier to test, debug, and reuse across different servers.

  • Always co_await next: Unless you are explicitly short-circuiting, ensure you co_await next(ctx, params) to maintain the execution chain. Forgetting to call or await next will cause the request to hang or terminate prematurely.

  • Handle exceptions: If a middleware performs risky operations (like database queries or network calls), catch their failures inside the middleware. An exception that leaves a middleware is answered as -32603. Wrapping next() in a try-catch sees exceptions from inner middleware, including a refusal from make_auth_middleware(), and from resource and prompt handlers, but not tool handler exceptions, which arrive as isError results.

  • Mind the order: Register global concerns (like logging or tracing) first, and more specific concerns (like tool-specific validation or authentication) later. Remember that the first registered middleware is the outermost.

  • Use the Context: Leverage the ctx object for logging information relevant to the middleware’s task. This helps in auditing and troubleshooting request flows in production environments.

  • Avoid Heavy lifting: Middleware should be relatively lightweight. If a middleware needs to perform a very expensive operation, consider if it should be part of the tool handler itself or if it can be optimized with caching.

  • Thread Safety: Ensure your middleware logic is thread-safe, as it may be called from multiple concurrent request handlers within the Boost.Asio io_context. Avoid sharing non-thread-safe state unless protected by mutexes or strands.

See Also

  • Tools for details on tool handlers.

  • Server Context for information on using the request context.

  • OAuth for an advanced use case involving authentication middleware.