← All articles

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 for syntax included in the proposal allocates aggressively and trips -fconstexpr-ops-limit for any non-trivial use. We iterate over a transient vector<info> with a plain for loop instead.
  • -fconstexpr-ops-limit defaults 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.

Talk to us about MCP infrastructure for your organisation. Get in touch.