C++26 Reflection in Production : Using P2996 With GCC 16
We use C++26 static reflection (P2996) to handle the serialisation and deserialisation of MCP tool inputs and outputs in our server. This article describes why reflection is the right primitive for that problem, how the integration is structured, and the constraints of the GCC 16 implementation that matter in practice.
The problem reflection solves
Every MCP tool in our server is defined by three things : A name, a JSON Schema for its input arguments, and a C++ handler that receives those arguments and returns a result. The schema is served to the LLM so it can call the tool correctly. The handler is invoked with parsed arguments at runtime.
Without reflection, the schema and the C++ signature are two independent declarations that must stay in sync. The available options are well known and all bad : Hand-write the schema as a string literal and accept the drift, or run a code generator pre-build and accept the extra toolchain.
Static reflection collapses the two declarations into one. The C++ struct is the source of truth ; The schema, the parser, and the dispatch entry point are derived from it at compile time. There is no second declaration to drift, and no extra toolchain.
How a tool is declared
A tool author writes a struct for the input, a struct for the output, and a handler :
struct ListDocumentsArgs {
std::string folder; // Folder to list (absolute path)
int max_results = 100; // Cap on returned items
bool include_archived = false;
};
struct ListDocumentsResult {
std::vector<std::string> documents;
};
REGISTER_TOOL(list_documents, ListDocumentsArgs, ListDocumentsResult,
[](const ListDocumentsArgs& args) -> ListDocumentsResult {
/* … */
});REGISTER_TOOL expands to a consteval entry point that walks the input and output structs with P2996 and produces three artefacts at compile time : The JSON Schema, materialised into a constexpr constant stored in static storage ; The deserialiser and serialiser, spliced into the translation unit as ordinary functions ; And the binding into the dispatch table for the typed handler. Runtime cost per tool invocation is two nlohmann::json conversions and one indirect call. Schema cost at runtime is zero.
The implementation in two primitives
The reflection layer is built on two helpers and one structural pattern.
1. enum_name and member_name
These return the identifier of an enumerator or a struct member as a std::string_view known at compile time :
template<auto V>
consteval std::string_view enum_name() {
return std::meta::identifier_of(^^V);
}
template<auto Member>
consteval std::string_view member_name() {
return std::meta::identifier_of(Member);
} From these two primitives plus iteration over nonstatic_data_members_of, the framework builds to_json, from_json, enum-to-string serialisation, and the JSON Schema emitter. The entire Reflect.h header is around 200 lines and covers every serialisation need across the codebase.
2. The transient vector<info> pattern
std::meta::info is P2996's reflection handle. To enumerate the members of a type :
consteval auto members_of() {
std::vector<std::meta::info> r;
for (auto m : nonstatic_data_members_of(^^T))
r.push_back(m);
return r;
}vector<info> works only as a transient inside a consteval function. You build it, walk it, and splice the result into the surrounding compilation. You do not return it out of a consteval call in a form that survives to runtime. Every reflection-driven step in the framework therefore lives inside a single consteval entry point and produces a spliced result.
GCC 16 specifics
Two implementation notes for GCC 16's preview branch :
- The
template forsyntax included in the proposal allocates aggressively and trips-fconstexpr-ops-limitfor any non-trivial use. We iterate over a transientvector<info>with a plainforloop instead. -fconstexpr-ops-limitdefaults too low. We compile with-fconstexpr-ops-limit=67108864.
Build setup
Enabling P2996 in CMake is two flags :
# cmake/McpReflect.cmake
target_compile_options(${target} PRIVATE
-freflection
-fconstexpr-ops-limit=67108864)
target_compile_definitions(${target} PRIVATE
MCP_HAS_REFLECTION=1) A helper function mcp_enable_reflection(target) wraps the above and gates it on the compiler. Framework code branches on MCP_HAS_REFLECTION for the rare build configurations where reflection is unavailable ; Those fall back to hand-written schemas.
Why it pays off
The framework around REGISTER_TOOL is shorter than the hand-written schemas it replaces by the time you cross roughly three converted types. From there, every new tool is free : One struct, one handler, no schema to maintain. Drift between the C++ signature and the LLM-visible contract is structurally impossible because there is only one declaration.
The broader context : See the pillar article on self-hosted MCP infrastructure and Why We Built Our MCP Server in C++26 for the reasons we run the platform on this stack.
