Boost C++ Libraries

PrevUpHomeNext

Design Notes

This chapter records the design decisions that are not obvious from the reference documentation alone.

Every public type in Taar is a template that instantiates differently for each user-supplied handler signature, request body, response body, and executor. There is no implementation that would benefit from being precompiled into a library: separating declaration from definition would force users to instantiate every combination explicitly. A header-only distribution keeps the build simple and matches the rest of the matcher/handler ecosystem on top of Boost.Beast and Boost.Asio.

The two main customisation points, response_from and rest_arg_cast, are exposed as tag_invoke overloads rather than virtual functions or class template specialisations.

tag_invoke is found by ordinary argument-dependent lookup, which means a user-defined return type or argument type can opt in to Taar by declaring a free function next to the type itself, without touching any namespace owned by the library. This is the same pattern used by boost::json::value_from and the C++26 sender/receiver proposal.

A soft error is something that an HTTP handler should be able to turn into a normal HTTP response: a missing query parameter, an unparseable path argument, a body that fails to deserialise. A hard error is something that takes the connection down: a peer disconnect mid-read, a write that fails because the socket is closed.

Both have pluggable handlers installed on a session via set_soft_error_handler and set_hard_error_handler, and both receive a taar::failure const& (see Error mapping per surface). The session wraps whatever escapes a request handler into a failure exactly once and hands it to the soft handler. The default soft handler answers library argument and body errors with 400 and the failure's text, a body limit with 413, and escalates everything else (user exceptions, internal codes) to the hard error handler, which sees the same failure; the connection is then closed. Transport errors go to the hard handler directly. Applications that throw their own exceptions from handlers install a soft error handler that recognises them with f.as<E>() (see example/rest_crud_server.cpp).

A matcher composition like

method == http::verb::get && target == "/api/{id}" && version == 11

is not evaluated eagerly. Each operator returns a small object that holds its operands by value and carries the combined matching logic. The result is one expression-template object per registered route, with no heap allocation and full inlining at the call site.

This is the same technique that Boost.Spirit, Boost.Hana, and Boost.Proto rely on. The cost is that diagnostics for an ill-formed matcher are longer than they would be with a runtime API; the benefit is that route dispatch is a flat sequence of ifs with no virtual calls.

The same expression also builds a type-level description of itself: the metadata_type of the resulting operand is an AST of leaf tags joined by and_, or_ and not_, and the operand stores what each leaf captured (the verb, the path template, the header value). That is the only information the OpenAPI generator uses, so matching logic and documentation can never disagree.

Taar is asynchronous from top to bottom, built on Boost.Asio C++20 coroutines. There is no thread-per-connection pool and no callback plumbing in user code; concurrency is expressed with co_await.

Everything is a coroutine

The TCP acceptor (taar::server::tcp) and the per-connection session (taar::session::http) are both taar::awaitable<void> coroutines spawned with boost::asio::co_spawn. One in-flight connection is one suspended coroutine frame, not a thread, so a single-threaded io_context already serves many simultaneous connections; running the io_context from several threads scales it across cores.

taar::awaitable<T> is an alias for boost::asio::awaitable<T, any_io_executor>, which is exactly boost::asio::awaitable<T>: the two are the same type and interchangeable, so a handler may return either and may co_await any Asio awaitable. The same coroutine runs on a plain io_context executor or on a strand. taar::use_awaitable and taar::use_awaitable_tuple are the matching completion tokens (throwing, and returning (error_code, result...)).

Executors and strands

The servers run every connection on its own strand, made from the executor connections are created from, so a multi-threaded io.run() needs no further care inside one connection: its operations never run concurrently with each other. Inside a handler, co_await boost::asio::this_coro::executor is that connection's strand. Work spawned on it (co_spawn(co_await this_coro::executor, ...)) is serialised with the connection; work spawned on the io_context executor runs in parallel with it. Objects shared between connections (a cache, a counter, a store) are touched from several strands, and therefore from several threads, so they need their own synchronisation; context_store and async_channel are thread-safe for that reason.

The plain server::tcp(host, port, session, signals) makes connection strands from the executor it is spawned on, so spawn it on the io_context (or thread pool) executor itself. Spawned on a strand, every connection strand would be layered on that strand and all connections would be serialised through it. When the server has to run on a strand, or connections should run on another executor, use the overload that takes the connection executor explicitly:

boost::asio::co_spawn(
    boost::asio::make_strand(io),
    taar::server::tcp(io.get_executor(), "0.0.0.0", "8080", session, signals),
    boost::asio::detached);

server::tls has the same pair of overloads. The acceptor itself always runs on a strand of its own.

Registration is configuration, not run-time state: the functions that add routes and hooks to a session or a JSON-RPC or MCP endpoint (register_request_handler, register_raw_handler, the error handler and filter setters, method, tool, resource, prompt and the like) are not synchronised against serving. Configure everything before io.run() starts the server.

Handlers see plain Boost.Beast and Boost.Asio types. A session handler passed to server::tcp is called with a boost::asio::ip::tcp::socket already moved onto the connection strand, and a raw handler receives a boost::beast::tcp_stream&& (or session::tls_stream&&, a plain beast::ssl_stream<beast::tcp_stream>) and a beast::flat_buffer&&. These streams keep Asio's default completion token, so pass taar::use_awaitable or taar::use_awaitable_tuple to their operations. taar::rebind_executor<T>, which rebinds an I/O object to the tuple token by default, remains as a convenience for the library's private objects and for client sockets.

Synchronous or asynchronous handlers

A request handler wrapped by taar::handler::rest may return its value directly or return a taar::awaitable<T>. The session detects which and does the right thing: a plain value is converted to a response immediately; an awaitable is co_awaited first. This means a handler that needs to talk to a database or call another service simply becomes a coroutine (its signature changes from T to taar::awaitable<T> and its body gains co_await/co_return) with no change to how it is registered or how its arguments are extracted.

The soft-error handler (see Soft errors vs hard errors) may likewise be a coroutine, so error responses can also be produced from asynchronous work.

Streaming responses

For responses that are produced incrementally (server-sent events, a large file, the output of a long-running job), a handler can return a taar::async_generator<T> or a taar::chunked_response<T>. These are themselves coroutines: the body co_yields each piece of the response and may co_await between pieces, while taar::chunked_response additionally lets the producer set the HTTP status and headers (by co_yielding taar::set_status / taar::set_header) before the first chunk goes on the wire. The session drives the generator and writes each yielded value using HTTP chunked transfer-encoding.

Request bodies stream too. A handler whose request type is streaming_request is invoked before the body is read and pulls it through a body_reader straight from the connection, so the session's body limit is bypassed and memory stays bounded by the handler's buffer. A body left unread when the response is produced closes the connection rather than being drained, since draining an arbitrarily large upload to keep one connection alive is rarely the right trade.

multipart/form-data is parsed by one incremental scanner used both for buffered forms (parse_multipart) and for streamed ones (multipart_reader). The scanner holds back only as many bytes as a delimiter could span, so a file part is delivered in pieces regardless of how the bytes arrived; a malformed body is an invalid_request_format soft error, and a part without a name is treated as malformed. multipart_reader::next_part discards whatever is left of the current part, so a handler may skip fields it does not care about.

Server-push streams that wait for events produced elsewhere (the MCP event stream, a chat room) read from a taar::async_channel<T>: any thread pushes, the generator co_awaits async_receive, and close() ends the stream.

Cancellation and graceful shutdown

Shutdown is cooperative. A taar::cancellation_signals pool hands a cancellation slot to every spawned coroutine; emitting on the pool (typically from a SIGINT/SIGTERM handler) stops the acceptor and cancels the idle read on every keep-alive connection, so idle connections close at once. Asio requires a cancellation to be emitted within the strand of the operation it cancels, so the servers register each connection's slot together with its strand and emit() dispatches the emission there; it may therefore be called from any thread, including one that is not running the io_context. The plain slot() form, which emits inline, remains for single-threaded use. A slot bound to the server coroutine by its spawner still stops it: the cancellation is re-emitted through the acceptor's strand rather than applied inline.

A request whose handler is running when the signal arrives is allowed to finish: the session shields a plain (non-streaming) handler from cancellation, records that shutdown was requested, writes the handler's response with Connection: close, and ends the connection. Streaming handlers stay cancellable, because an open-ended stream would otherwise hold the process up; the cancellation propagates into the generator's pending co_await and the connection is dropped.

Cancellation reaches nested work on every path, and the frames it interrupts unwind (RAII runs); test/test_cancellation.cpp checks each one and example/cancellation_server.cpp shows them side by side:

  • shutdown cancels a streaming handler wherever it is suspended: a timer between chunks, a helper coroutine several co_awaits deep, a spliced inner stream, an async_generator, both branches of a && b or a || b, or a loop polling this_coro::cancellation_state. Plain handlers, including one reading a streamed request body, are shielded: they finish, and their answer carries Connection: close. A shielded body read waits for the client, at most the body timeout (see Timeouts), so a client that stops sending mid-upload holds that connection for that long and is then answered 408.
  • client disconnect during a stream is noticed at the stream's next write; the generator is then destroyed with its nested frames. A stream suspended in a long wait between chunks keeps waiting until that wait ends, since the session is not reading the socket while it streams.
  • JSON-RPC endpoint::cancel(id) emits on the request's request_scope, interrupting every co_await in the method; a method can also poll scope.cancelled(). The answer is a -32800 error.
  • MCP notifications/cancelled does the same for a tool call, over HTTP (the cancelled call is answered 202 with no body) and stdio (no reply line), as well as for a tool suspended in elicit().

basic_http is parameterised on its stream type. The default is Beast's tcp_stream; session::https instantiates it over beast::ssl_stream<tcp_stream>, and server::tls owns the handshake so the session never sees a half-established connection. On exit a TLS session sends close_notify (bounded by a five second timer) before the TCP half-close. Nothing in the handler layer depends on the stream type, so every route, context and protocol layer works unchanged over TLS.

Timeouts

A connection is bounded by four timeouts, modelled on nginx and set on the session (set_idle_timeout, set_header_timeout, set_body_timeout, set_send_timeout; zero disables one):

Timeout

Runs

On expiry

Default

idle (keepalive_timeout)

while waiting for the first byte of a request, the first one or the next on a keep-alive connection

the connection is closed silently

60 s

header (client_header_timeout)

from the first byte of a request until its header is complete

408 Request Timeout with Connection: close, through the response filter, then close

30 s

body (client_body_timeout)

for each read of a request body, buffered by the session or made through body_reader / multipart_reader: the longest the client may stay silent

408 (a streaming handler sees a failure carrying beast::error::timeout, which the default soft error handler answers with 408), then close

60 s

send (send_timeout)

for each write: the longest a write may stay blocked, for example on a client that stopped reading

the connection is closed

60 s

Only time spent in the guarded operation counts. A handler's execution never does, and neither does the time a streaming generator spends between chunks, so a server-sent-events stream with long gaps needs no special configuration. The idle and send timeouts use the stream's own expiry (expires_after on the lowest layer, also under TLS), set before the operation and cleared (expires_never) after it; Beast closes the socket when it fires. The header and body timeouts must leave the connection open for the 408, so they use a timer that cancels the pending read on the socket instead of closing it. That cancellation does not travel through the coroutine's cancellation slot, so it also bounds the body reads that are shielded from shutdown: a client that stalls mid-upload delays a graceful shutdown by at most the body timeout. The TLS handshake keeps its own timeout (server::tls), and the close sequence (close_notify, lingering close) keeps its timers.

An expired timeout is the client's doing, not a server failure: it is not reported to the hard error handler.

The Date header

Every response the session writes carries Date (RFC 9110 6.6.1) unless the handler or the response filter already set one, including error answers, 304, HEAD responses, the header of chunked responses and the 400/408 protocol answers. The value is formatted at most once per second per thread, so it costs neither a lock nor a formatting per response. set_date_header(false) turns it off. A raw handler returning a message_generator writes what it produces.

Unmatched requests and HEAD

A request that matches no route is answered 405 Method Not Allowed with an Allow header when some route accepts the same path under another method, and 404 otherwise (including when the method is accepted but a header, cookie or version constraint rejected the request). HEAD is served through a matching GET route: the handler runs with the HEAD request and only the response header is written, so Content-Length is what the GET would carry. Both behaviours use the matcher metadata (a route must be a conjunction of one target == and method == constraints to take part) and both can be switched off with set_method_not_allowed and set_head_from_get.

Responses are always aligned with the request they answer: same HTTP version, and keep-alive only if the request allows it, the handler did not disable it, and the server is not shutting down. Pipelined requests are served in order from one per-connection buffer. A peer that closes an idle keep-alive connection is not an error; a generator that throws after chunks are on the wire truncates the response and reports the exception to the hard error handler.

The HTTP session, the matchers and handler::rest form the transport and REST layer. JSON-RPC, MCP and OpenAPI are separate objects layered on top of it rather than modes of the session:

  • each layer is user-owned and keeps its own registry, so a program can have several JSON-RPC endpoints or none;
  • each layer hands the session an ordinary handler for one route, so routing, keep-alive, limits, contexts and error handling are shared;
  • MCP composes a JSON-RPC endpoint instead of re-implementing it, and custom MCP methods are plain JSON-RPC methods;
  • REST has no registry object of its own: routes are registered directly on the session, and the OpenAPI generator observes those registrations.

The binding machinery is shared too. jsonrpc::detail::bind_method turns a callable plus argument providers into an invoker and a parameter description; handler::rest, JSON-RPC methods, MCP tools and MCP resources all use the same context-injection rules and the same rest_arg_cast conversions, which is what lets one int add(calc&, int, int) serve all three surfaces unchanged. Providers that read the HTTP request (header_arg, cookie_arg, query_arg) are accepted on every surface as well, because every call type carries the request it arrived on.

A handler can ask for state in three scopes by declaring leading parameters:

  • connection: basic_http<T> constructs one T when a connection is accepted and destroys it when the connection closes. Keep-alive makes this the natural home for per-client counters and caches.
  • keyed: a context_store<Key, T> holds one T per key with idle expiry; with_session resolves the key from a cookie or header (minting one when allowed), locks the entry for the duration of the request and injects the T. Under a streaming handler the lock covers only the call that produces the stream; the chunks then flow unlocked, so one client's open stream never blocks its other requests, and the minted key travels in the stream's headers. The MCP endpoint uses the same store for its sessions keyed by Mcp-Session-Id.
  • none: handlers without context parameters work in every scope.

Contexts are matched by type, in any order, so adding a context parameter never shifts the argument providers. T& and T const& are preferred; std::shared_ptr<T> is accepted for handlers that need to retain the object beyond the request.

context_store is designed around three facts: requests for the same key may arrive concurrently on different connections and threads, so each entry has an asynchronous lock that serialises them while requests for different keys run in parallel; entries must disappear when clients go away without telling us, so every access touches a timestamp and sweeps expired entries opportunistically (no background timer); and a locked or in-flight entry must never be swept. Keys minted by the store are 128 random bits as hexadecimal, safe in cookies and headers. Set-Cookie uses Path=/; HttpOnly; SameSite=Lax by default and never sets an expiry: the store's idle timeout is the session lifetime.

Authentication, request ids, rate limits and similar cross-cutting checks need to run before the handler, see the request, and either let it pass, attach something for the handler, or answer it themselves. Taar has two places for them.

Session interceptors

basic_http::add_interceptor(fn) runs fn on every request; add_interceptor(matcher, fn) on the requests the matcher accepts. They run in registration order right after the request header is parsed:

  • before a route is selected, so they apply to every route, raw (WebSocket) handlers included;
  • before the 405 Method Not Allowed, 404 and HEAD-from-GET handling, so an unauthenticated client learns nothing about which paths or methods exist;
  • before any body byte is read.

fn takes the session's contexts by type (as handlers do), then (http::request_header<> const&, matcher::context&), and returns taar::interceptor_result, an optional response, directly or as an awaitable. taar::proceed() is std::nullopt: the next interceptor runs, then routing. taar::reject(status, text) builds a text/plain response; any response short-circuits: later interceptors and the handler do not run.

A rejection is written like any other response: its version and keep-alive follow the request and the response filter runs on it, so a browser sees the CORS headers on a 401 and can read it. The request body has not been read; when the request declares one the connection closes after the rejection rather than reading and discarding a body the server has already refused (an upload of a gigabyte gets its 401 at once). An interceptor that throws is handled like a handler that throws: the soft error handler receives the failure and can recognise a domain exception with f.as<E>().

The matcher form evaluates the matcher on the header exactly as a route does. Path arguments a target template extracts go into a scratch context that is discarded; the interceptor receives the request's own context. Two scopes come up constantly:

// CORS preflights carry no credentials: let them through.
session.add_interceptor(!(method == http::verb::options), require_token);
// A public health check.
session.add_interceptor(!(target == "/health"), require_token);

Scope carefully when several protocols share a session: DELETE /mcp ends an MCP session, so an interceptor meant for DELETE /tasks/{id} should name the target rather than only the method.

Request attributes

matcher::context carries a small type-indexed bag, request_attributes, with set(value), find<T>() and contains<T>(). An interceptor stores what it learned (ctx.attributes.set(principal {...})) and the handler that later runs reads it, directly through the context or with the attribute_arg<T>() provider, which works in rest, jsonrpc::endpoint::method and mcp::endpoint::tool alike. One value is kept per type, so define a type per attribute rather than storing bare strings. Routing resets only the path arguments between candidate routes; attributes survive it. An absent attribute is argument_not_found, so a std::optional<T> parameter or with_default makes it optional. attribute_arg is not a client input: OpenAPI, the JSON-RPC parameter list and the MCP inputSchema leave it out.

Route guards

handler::guard(check, inner) decorates one route. check has the interceptor signature and runs when the route's handler is invoked; on a rejection that response is the route's answer (converted to the inner's response type, or sent as a one-shot chunked response when the inner streams), otherwise the inner handler runs and sees the attributes the check set. The guard forwards params() and the path-argument names, so OpenAPI and the registration checks see the inner handler, and it composes with with_session in either order: inside with_session the check can ask for the keyed context.

Choose between the two by when the decision must happen. A guard runs after routing and after the body has been read (the inner handler decides how the body is read), so it suits checks that belong to one route and may depend on its keyed state. A scoped interceptor (add_interceptor(method == http::verb::delete_ && target == "/tasks/{id}", check)) runs before the body is read and before routing answers, which is what a check guarding an upload, or hiding which routes exist, needs.

Domain code throws plain exceptions and knows nothing about HTTP, JSON-RPC or MCP. The library itself reports every error through one value type, and each layer maps that value to its protocol where it crosses into it, with a default and a hook.

One error value: failure

boost::taar::failure (in <boost/taar/core/error.hpp>) carries

  • an error_code from any category: Taar's own taar::error, Asio, Beast, Boost.JSON, generic errc, jsonrpc::error, mcp::error, or a user category;
  • a message for this occurrence that reads on its own ("query parameter 'tag' occurs more than once", "request body exceeds the read limit of 100 bytes"). It does not repeat the code's generic text;
  • optional structured data, a boost::json::value;
  • an optional nested cause, so a failure explains itself in layers;
  • the source location where it was raised;
  • optionally the foreign exception it was made from.

text() renders the failure for people: each level prints its message, or the code's own text when the message is empty, and causes follow in (caused by: ...). chain() adds [category:value] tags to every level and is meant for logs. is(code) tests the outermost code, has(code) the whole chain, and root() returns the innermost cause.

failure::from_exception(eptr) turns any exception into a failure: a taar::exception yields its own failure unchanged; a boost::system::system_error or std::system_error yields its code with the exception's text as the message; any other exception becomes taar::error::unknown_exception with what() as the message. In every case but the first the original exception is kept, so rethrow() throws the user's own type again and as<E>() finds it.

result<T> and taar::exception

taar::result<T> is boost::system::result<T, failure>: the non-throwing shape used by argument providers and parsers. Accessing value() on a failed result throws through Boost.System's ADL hook throw_exception_from_error, which Taar defines for failure to throw taar::exception.

taar::exception is the one exception type the library throws. It carries a failure (error()), and its what() is the failure's chain(). Registration-time programmer errors (a duplicate tool name, a path_arg that names no placeholder, an invalid template) are reported when the handler is registered: duplicate and unknown names as std::logic_error, template and path_arg mismatches as taar::exception.

How the binder nests failures

Argument providers return taar::result<V>. An absent argument is failure{error::argument_not_found} with no message; with_default and std::optional parameters absorb exactly that code. Any other provider failure, or an exception thrown by a rest_arg_cast (built-in or user), becomes the cause of an argument-level failure: code error::invalid_argument_format, message argument page (position 1) is invalid, data {"argument": "page", "position": 1}. Its text() then reads, for instance:

argument page (position 1) is invalid (caused by: value 'abc' is not a number (Invalid argument))

A constrained violation is not wrapped: it is error::constraint_violated with a message such as page must be >= 1 and data {"argument": "page"}. JSON-RPC and MCP arguments (rpc_arg, mcp_arg, uri_arg) follow the same scheme.

Mapping by code, per surface

Each default mapper looks only at the code; none of them inspects exception types.

Surface

Default

Hook

REST

http_status_for(f) decides: Taar's request codes (argument not found or ambiguous, invalid argument, request, URL, content type, cookie, boolean or number format, constraint violated) -> 400; Beast body_limit -> 413; header_limit -> 431; beast::error::timeout (a request body read that timed out) -> 408. A failure mapped to a 4xx status is answered with that status and f.text() as a text/plain body. Everything else maps to 500 and is escalated to the hard error handler instead of being answered.

set_soft_error_handler(h), h(taar::failure const&) returning anything response_from accepts, or an awaitable of it

JSON-RPC

default_error_mapper(f): a jsonrpc::error code -> that code with the failure's message (or the code's text); a Taar code other than unknown_exception -> -32602 Invalid params with data equal to the failure's object data plus "message": text(), or the text() string when there is no object data; anything else (user exceptions) -> -32603 Internal error with the failure's message and no data. Unknown methods are -32601; a cancelled request is -32800.

set_error_mapper(fn), fn(taar::failure const&) -> error_info

MCP tools

default_tool_error_handler(f): Taar (except unknown_exception), jsonrpc::error and mcp::error codes stay protocol errors and go through the JSON-RPC mapper; anything else becomes a result with isError: true and f.text() as its text content.

set_tool_error_handler(fn), with fn(taar::failure const&) -> std::optional<std::string>; nullopt means protocol error

The MCP layer has its own category, mcp::error. Where MCP assigns a wire code the enumerator's value is that code and is sent as is: resource_not_found (-32002) and request_cancelled (-32800). The other enumerators describe failures of server-to-client requests (request, elicit, create_message, list_roots): client_error when the client answers with a JSON-RPC error (the failure's message is the client's message, its data {"code": n, "data": ...}) and unknown_session. A timeout is errc::timed_out and a session that ends first errc::operation_canceled, each with a message naming the method and the session.

Escalation

On the REST surface a failure whose status would be 500 (a user exception, an internal or transport code) is not turned into a response by the default soft handler: it is escalated to the hard error handler with the same failure, and the connection is closed. The hard handler is where an application logs; f.chain() gives the tagged rendering with the whole cause chain, and f.where() the raise site. A soft handler or mapper that throws is treated the same way: the session escalates, and the JSON-RPC and MCP endpoints fall back to their default mapper, so an error in error handling cannot take a connection down unexpectedly.

Recognising your own exceptions

A hook recognises a domain exception without a try/catch ladder with failure::as<E>(), which returns a copy of the kept exception as a std::optional<E> if it is an E (or derives from it) and std::nullopt otherwise. It returns a copy, not a pointer, because whether std::rethrow_exception copies the exception object is unspecified (MSVC copies it), so the address of a caught object cannot be handed out portably:

session.set_soft_error_handler([](taar::failure const& f)
{
    if (auto const e = f.as<not_found>())
        return taar::response_builder(e->what()).set_status(http::status::not_found);
    return taar::response_builder(f.text()).set_status(taar::http_status_for(f));
});

rpc.set_error_mapper([](taar::failure const& f) -> taar::jsonrpc::error_info
{
    if (auto const e = f.as<not_found>()) return {-32004, e->what(), std::nullopt};
    return taar::jsonrpc::default_error_mapper(f);
});

Domain code that wants a specific mapping without a hook can also throw taar::exception{taar::failure{code, message}} with a code the default mappers already know.

Where the specifications leave room, Taar makes these choices:

JSON-RPC 2.0
  • Every JSON response, including error objects, is HTTP 200. Only notifications (and all-notification batches) answer 202 with an empty body.
  • Batches are processed element by element; one failure never aborts its siblings. An empty batch is a single -32600 object. An invalid element is answered with -32600 and its id if that was a string, number or null.
  • Parameters bind by name from a params object or by position from a params array; positions count only the rpc_arg providers, so HTTP providers mixed into a method do not shift them. A short array leaves trailing parameters missing; extra elements and unknown members are ignored; a scalar params is -32602.
  • Every request runs under a request_scope registered by id (namespaced by the MCP session when there is one). endpoint::cancel(id) fires its cancellation slot, so awaits inside the callable are interrupted; a cancelled JSON-RPC request answers -32800. std::optional<T> and with_default make a parameter optional. Method names are case-sensitive; registering a name twice throws at startup.
MCP
  • Supported protocol revisions are 2025-11-25, 2025-06-18 and 2025-03-26. A request for a supported version is echoed; anything else is answered with the newest supported version, as the specification asks. A request carrying an unsupported MCP-Protocol-Version header is 400.
  • Stateful endpoints mint Mcp-Session-Id on initialize; later requests without it are 400, with an unknown or expired id 404, and DELETE ends the session. Stateless endpoints issue and require no id.
  • JSON-RPC batches are rejected (-32600), following the current transport. A JSON-RPC response or notification posted by the client is acknowledged with 202.
  • Server-to-client requests (request, and create_message, elicit, list_roots on top of it) go out over the session's event stream (or stdout on stdio) with server-side ids; the client's response is matched by id and routed to the awaiting coroutine without taking the session lock, so a tool can await its own client while it holds the session. Other requests from the same session wait meanwhile, so the wait is bounded by server_info::request_timeout (five minutes by default) unless a call passes its own. Failures throw taar::exception: an error reply carries mcp::error::client_error with the client's code and data, a timeout errc::timed_out, the session ending errc::operation_canceled. session_info carries the client's identity and declared capabilities, so a tool can check before asking. on_client_notification hooks notifications the client sends, such as notifications/roots/list_changed.
  • Prompts are registered like tools (prompt(name, description, callable, mcp_arg...)); a string result becomes one user text message, an array is taken as the messages, an object with messages is passed through. The prompts capability is advertised only when prompts exist.
  • resources/subscribe is stateful-only (the subscription lives in the session); resource_updated(uri) notifies subscribed sessions, and the *_changed() helpers broadcast list_changed. Stateless endpoints answer subscribe with -32601 and do not advertise it.
  • */list methods paginate when server_info::page_size is set: cursors are opaque strings (-32602 when invalid), nextCursor is present while more items remain.
  • tools/call renders scalar results as text and wraps them as structuredContent: {"result": ...}; object results are passed through as structuredContent; void yields an empty content array. Unknown tools and bad arguments are -32602; an unknown resource is -32002.
  • Resource templates follow RFC 6570: {var} stops at /, ?, # and ,; {+var} spans them; {#var}, {.var}, {/var}, {;var}, {?a,b} and {&a,b} are optional when their prefix is absent, so one template serves calc://x/{i} and calc://x/{i}?format=json. uri_arg binds the captured values; an absent optional one is argument_not_found, which a std::optional or with_default parameter absorbs.
  • tools/list carries an outputSchema derived from the callable's return type with the same wrapping (described structs and string maps as is, scalars, strings, enums and sequences under result), unless tool_spec::output_schema sets or disables it. tool_spec also adds title and annotations (read-only, destructive, idempotent, open-world hints).
  • logging/setLevel is stored per session (initially server_info::default_log_level); log() and log_all() emit notifications/message only at or above it. The logging capability is always advertised.
  • completion/complete consults completers registered per prompt or resource-template argument (complete_prompt, complete_resource); an argument without one answers an empty list, an unknown ref is -32602, and at most 100 values are returned with hasMore and total. The completions capability appears once a completer exists.
  • The GET event stream is one per session (409 for a second), fed by notify/broadcast; messages queued before a stream opens are kept, up to server_info::stream_history of them (oldest dropped first), so a session that never opens its stream cannot grow without bound. An idle stream carries a : keepalive comment every server_info::stream_keepalive (15 s by default), so a dropped connection is noticed and the session freed for a reconnect. Deleting or sweeping the session ends the stream. notify does not refresh the session's idle timer. A tool reaches its own stream through the injected session_info.
  • Every stream event carries an id; a session keeps the last server_info::stream_history events (100 by default). A GET with Last-Event-ID first replays the events newer than that id which an earlier stream had already taken (those the client may have missed in flight), then continues with whatever is still queued, so a reconnecting client sees each event once and in order. An event that fell out of the bounded history before the reconnect is gone.
  • Tools return plain values (text plus structuredContent) or the typed results in mcp/content.hpp: image, audio, embedded_resource, text, or a full tool_result with several items and an explicit isError. A resource callable may return blob for binary contents; base64 is produced by base64_encode or the from_bytes helpers.
  • notifications/cancelled is handled before the session lock is taken, so it can reach a tool that is running; the tool's awaits are interrupted and, as the specification asks, no response is sent for the cancelled request. _meta.progressToken on a request enables request_scope::report_progress, which emits notifications/progress on the session's stream; without a token the reports are dropped.
  • The stdio transport (serve_stdio) runs one implicit session: created on initialize, removed at end of input. Requests before initialize are answered -32600. Requests run concurrently with reading (still serialised per session) so a cancellation can arrive mid-call. Replies and notifications share one writer so lines never interleave. When the input closes, calls still running finish and their replies are flushed before the session is removed and the coroutine returns.
OpenAPI
  • A route is expressible when its matcher is a conjunction of exactly one target == ... and at least one method == ... (several may be joined with ||), plus optional header, cookie and version constraints. Negation, != and || over paths are rejected at compile time rather than documented approximately.
  • Header and cookie constraints become required parameters with a const schema. Path placeholders not bound by a path_arg are still declared; a greedy {*name} renders as {name} with a note.
  • A container parameter fed by query_arg is a comma-separated list in one occurrence (explode: false); query_list_arg collects repeated keys, the form/explode default.
  • with_default values render as default; constrained(provider, {...}) renders its rules as JSON Schema keywords (minimum, pattern, enum, minItems, format, examples, ...) and enforces them at request time on every surface (400, -32602, tool error), so the document and the behaviour cannot drift apart.
  • add_security_scheme fills components.securitySchemes; set_security and operation::security state requirements, and a route or provider reading a scheme's header, cookie or query parameter (or Authorization for an http scheme) is documented as that requirement instead of a parameter. anonymous emits security: [].
  • boost::described types carry a title and are hoisted into components.schemas, where parameters and responses refer to them by JSON Reference; set_inline_schemas keeps them in place.
  • The response schema is derived from the callable's return type when the built-in conversions apply; a hand-written http::response documents only a generic 200. Per-operation responses override the default.
  • Schemas come from json_schema_for<T>(): boost::described structs render their members (optional members are not required), described enums render as string enumerations, sequences and string-keyed maps render items and additionalProperties. MCP inputSchema uses the same function, so both documents agree by construction.

handler::htdocs is a static file handler meant to face the internet directly. Its rules follow the HTTP semantics of RFC 9110 rather than any particular server's configuration language.

Resolution and safety

The file is the route's greedy capture (target == "/static/{*path}" serves root/app.css for /static/app.css), selected as the {*} entry or the only path argument, or named by htdocs_options::path_arg when a route has several placeholders; without path arguments the whole request path is used. The query never takes part.

  • The path is percent-decoded by Boost.URL. An encoded / (%2F), backslash (%5C) or NUL (%00) anywhere in the path, and any . or .. segment after decoding, answer 400. Names merely containing dots (a..b.txt) are fine.
  • The resolved file, symbolic links followed, must lie under the canonical root; a link pointing outside answers 404 unless follow_symlinks_outside_root. Names starting with . answer 404 unless serve_hidden; .well-known is always served. Refusals are 404, not 403, so the tree cannot be probed.
  • Only regular files are served: a directory requested without a trailing slash is redirected (301, query kept) to the slash form; with the slash the index_files are tried in order, and none means 404. There are no directory listings.
  • GET and HEAD are served, anything else is 405 with Allow: GET, HEAD.
  • Error responses are fixed text/plain reason phrases. They never echo the request, so a crafted path cannot inject markup.
  • fallback_document turns misses into the application shell for single-page applications: a missing path whose last segment has no extension (or that fallback_when accepts) gets the document with Cache-Control: no-cache; missing assets stay 404.

Every response is a typed http::response<segmented_body>, so the session's response filter and HEAD handling apply to files and errors alike, and with_session and guard can wrap the handler. The on_response hook sees the header of every served file (200, 206, 304) for per-file additions such as Content-Disposition.

Content types

The media type comes from the last extension, case-insensitively: first mime_types (user additions and overrides), then a built-in table of web, image, font, audio, video, archive and office formats (mime.hpp), else default_mime_type. Text types get ; charset=utf-8 unless the configured value already has a parameter. X-Content-Type-Options: nosniff accompanies every served file unless nosniff is turned off.

Validators and conditional requests

Last-Modified is the modification time at second resolution (never later than the current time). The ETag is strong and derived from size and modification time, plus the content coding for a precompressed variant, so every representation has its own validator. Preconditions are evaluated in the order RFC 9110 13.2.2 prescribes:

  1. If-Match (strong comparison, * and lists): 412 when nothing matches;
  2. otherwise If-Unmodified-Since: 412 when modified since;
  3. If-None-Match (weak comparison): 304 when a tag matches;
  4. otherwise If-Modified-Since: 304 when not modified since.

Invalid dates are ignored. A 304 carries ETag, Last-Modified, Cache-Control and Vary and no body or content metadata.

Cache-Control comes from cache_rules, a first-match list of predicates or globs on the relative path, then default_cache_control (none by default). The common front-end policy is one call: cache_rules::immutable_hashed() caches names carrying a content hash (app.3f9a1c2e.js, chunk-5ZQ3X2AB.js) for a year as immutable and sends no-cache for HTML. The pattern is: emit hashed names from the build, reference them from HTML that is revalidated on every load, and a deployment is picked up by the next page load while assets never need revalidation.

Ranges

Full responses announce Accept-Ranges: bytes. A single range answers 206 with Content-Range; several answer multipart/byteranges with a random boundary, after overlapping and adjacent ranges are merged. More than max_ranges specs (16) or a malformed header are ignored and the whole file is sent; a header with no satisfiable range answers 416 with Content-Range: bytes */size. If-Range holds with a strong tag that matches or a date equal to Last-Modified; otherwise the whole file is sent. Ranges are byte windows of the open file: nothing is buffered.

Precompressed variants

With precompressed = {"br", "gzip"} (the default) a file app.js with a sibling app.js.br or app.js.gz that is not older than the original is served with Content-Encoding when Accept-Encoding accepts the coding. The highest q wins, ties go to the configured order, and identity wins only when the client ranks it higher explicitly; identity;q=0 is respected. A request without Accept-Encoding gets identity. Files with a sibling carry Vary: Accept-Encoding, on 304 too. A request with Range is served from identity so that offsets always refer to the uncompressed bytes. Nothing is compressed on the fly.

Caches

A metadata cache (resolution, stat results, variants, validators, media type) keyed by the decoded relative path holds metadata_cache_entries (4096) entries and checks the file system again on the first use after revalidate_after (1 s). Within that window a changed file can still be described by its previous validators; a file whose size changed or that vanished is noticed when it is opened and looked up afresh, so a body never contradicts its Content-Length. Files up to memory_cache_max_file_size (64 KiB) are kept in memory, up to memory_cache_max_total (32 MiB) in all, and served from a shared immutable buffer. Copies of a handler share the caches behind one mutex, which is what lets one handler serve every connection strand.

Blocking file I/O

stat, open and read are synchronous calls on the connection's thread; HEAD and 304 never open the file and the caches absorb most stats, but a slow disk still stalls the thread that serves the request. Size the thread pool accordingly. Asynchronous file I/O (Asio stream_file, io_uring) and sendfile are future work.

Out of scope

Deliberately not provided: directory listings, on-the-fly compression, bandwidth throttling, .htaccess files and rewrite rules (use matchers and several routes), access control (use interceptors and guards), custom error pages beyond the soft error handler and the response filter, WebDAV, sendfile and asynchronous file I/O.


PrevUpHomeNext