![]() |
This chapter records the design decisions that are not obvious from the reference documentation alone.
tag_invoke
overloads instead of virtual functions or template specialisations.
context_store keeps them safe.
failure),
and how each layer maps it to its protocol.
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.
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...)).
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.
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.
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.
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:
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.
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.
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.
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 ( |
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 ( |
from the first byte of a request until its header is complete |
|
30 s |
|
body ( |
for each read of a request body, buffered by the session or made
through |
|
60 s |
|
send ( |
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.
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.
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:
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:
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.
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.
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.
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:
405 Method
Not Allowed,
404 and HEAD-from-GET handling,
so an unauthenticated client learns nothing about which paths or methods
exist;
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.
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.
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.
failure
boost::taar::failure (in <boost/taar/core/error.hpp>)
carries
error_code from any
category: Taar's own taar::error,
Asio, Beast, Boost.JSON, generic errc,
jsonrpc::error, mcp::error,
or a user category;
boost::json::value;
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.
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.
Each default mapper looks only at the code; none of them inspects exception types.
|
Surface |
Default |
Hook |
|---|---|---|
|
REST |
|
|
|
JSON-RPC |
|
|
|
MCP tools |
|
|
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.
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.
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:
200.
Only notifications (and all-notification batches) answer 202 with an empty body.
-32600 object. An invalid element is answered
with -32600
and its id if that was
a string, number or null.
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.
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.
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.
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.
-32600), following the current transport.
A JSON-RPC response or notification posted by the client is acknowledged
with 202.
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.
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.
{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.
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.
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.
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.
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.
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.
const schema. Path placeholders not bound
by a path_arg are still
declared; a greedy {*name} renders as {name}
with a note.
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.
http::response
documents only a generic 200.
Per-operation responses
override the default.
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.
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.
/
(%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.
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.
index_files are tried
in order, and none means 404. There are no directory listings.
Allow: GET, HEAD.
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.
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.
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:
If-Match (strong comparison, * and lists): 412 when nothing matches;
If-Unmodified-Since: 412 when modified since;
If-None-Match (weak comparison): 304 when a
tag matches;
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.
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.
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.
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.
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.
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.