cpp26-reflection · part 19

reflect_llmschema: C++ functions to LLM tool-use schemas, at compile time

· upd 2026-10-06· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

Correction, 6 October 2026. This post presents reflect_llmschema as a published library, and it is not one. The repository linked below holds a README and one “Initial commit” from 22 June 2026: no code, release, tag or licence. There is no find_package target and no vcpkg recipe (the post said one was attached below, and none was). The Compiler Explorer demo is a reduced stand-in. It has openai_tool_json<Args>("name") where the post writes openai_tool<&fn, "name">, and it maps bool, integers, floating-point numbers, strings and nested structs. It has no field descriptions, format, default values, enums, std::optional handling or ranges (a comment in its source mentions enums, but the code has no enum case), and no Anthropic or MCP output. It also puts every field in required. The post’s example said the output is the JSON shown at the top, with a field description, "format": "email", a default and a required list of two names. That claim does not hold. I ran the demo on Compiler Explorer (clang-p2996) and on GCC 16.2.0 (where annotation_of_type is missing and I looked the annotation up with annotations_of_with_type), and both print this for its CreateUserArgs (name, email, admin, age): {"type":"function","function":{"name":"create_user","description":"Create a new user account.","parameters":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"},"admin":{"type":"boolean"},"age":{"type":"integer"}},"required":["name","email","admin","age"]}}}. I also fed the struct from this post to the same code on GCC 16.2.0. It printed the struct description, the three property types and "required":["name","email","admin"], and nothing for the field descriptions, the format or the default. The core walk in this post leaves out append_enum_schema, append_array_schema and append_required_list, so it does not compile as shown (GCC 16.2.0 refuses an instantiation that reaches a function the code never declares: 'append_object_schema' was not declared in this scope, in my test with that function absent). In the MCP example list_flights and cancel are never declared, and mcp.hpp, llm::mcp::server, Weather and FlightBooking do not exist. I removed the claim that the whole library is under 500 lines, because there is no library to count, and I removed the claim that the dispatch uses a reflect_json library: no such repository exists. The text below is otherwise left as published, so you can read it against this note.

No published library. github.com/wrocpp/reflect_llmschema is a placeholder repository with a README only. This post describes a design sketch.

Arc 6 kicks off with the use case that didn’t exist when Arc 1 started: feeding C++ functions to large language models as tools.

In 2026 OpenAI’s function-calling API, Anthropic’s tool_use, and Anthropic’s MCP all expect a JSON-Schema object describing each callable tool: its name, parameters, types, descriptions. Today your C++ function and its schema live in two places, and they drift:

{
  "name": "create_user",
  "description": "Creates a new user account.",
  "parameters": {
    "type": "object",
    "properties": {
      "name":  { "type": "string", "description": "Display name" },
      "email": { "type": "string", "format": "email" },
      "admin": { "type": "boolean", "default": false }
    },
    "required": ["name", "email"]
  }
}

With a library like reflect_llmschema (sketch: there is no such header, and the demo below does not handle the field annotations):

#include <reflect_llmschema/schema.hpp>  // sketch: there is no such header

struct [[=llm::description("Creates a new user account.")]] CreateUserArgs {
    [[=llm::description("Display name")]]          std::string name;
    [[=llm::format("email")]]                      std::string email;
    [[=llm::default_value(false)]]                 bool admin = false;
};

std::string create_user(CreateUserArgs args);

int main() {
    std::println("{}", llm::openai_tool<&create_user, "create_user">());
}

The aim is the JSON above, computed at compile time and baked into .rodata, so that renaming a field renames the schema entry. No code here produces that JSON: the walk below emits no description, format or default, and the embedded demo prints a smaller shape (see the correction at the top for the real output).

The mapping

JSON Schema’s primitive type set is small. We map C++ types into it by walking the parameter struct with reflection and dispatching:

C++ type JSON Schema
bool {"type": "boolean"}
int, long, std::int32_t, … {"type": "integer"}
float, double {"type": "number"}
std::string, std::string_view {"type": "string"}
std::vector<T>, any range {"type": "array", "items": ...recurse}
std::optional<T> ...recurse, drop from required list
aggregate struct {"type": "object", "properties": ...recurse, "required": [...]}
enum class {"type": "string", "enum": ...enumerators}

That would be a few dozen lines of if constexpr ladders plus template for recursion. The demo at the end of the post covers the first four rows and nested structs, and nothing else in this table.

The core walk

// Sketch: append_enum_schema, append_array_schema and append_required_list are
// never defined, so an instantiation that reaches them is refused. The demo has
// no enum or range case, and its "required" lists every field.
template <typename T>
consteval void append_type_schema(std::string& out) {
    if constexpr (std::is_same_v<T, bool>)             out += R"({"type":"boolean"})";
    else if constexpr (std::is_integral_v<T>)           out += R"({"type":"integer"})";
    else if constexpr (std::is_arithmetic_v<T>)         out += R"({"type":"number"})";
    else if constexpr (std::is_convertible_v<T, std::string_view>)
                                                        out += R"({"type":"string"})";
    else if constexpr (std::is_enum_v<T>)               append_enum_schema<T>(out);
    else if constexpr (std::ranges::range<T>)           append_array_schema<T>(out);
    else                                                append_object_schema<T>(out);
}

template <typename T>
consteval void append_object_schema(std::string& out) {
    out += R"({"type":"object","properties":{)";
    bool first = true;
    constexpr auto ctx = std::meta::access_context::unchecked();
    template for (constexpr auto m
                  : std::define_static_array(
                      std::meta::nonstatic_data_members_of(^^T, ctx))) {
        if (!first) out += ',';
        first = false;
        out += '"';
        out += std::meta::identifier_of(m);
        out += "\":";
        append_type_schema<[: std::meta::type_of(m) :]>(out);
    }
    out += "},\"required\":[";
    append_required_list<T>(out);
    out += "]}";
}

template for unrolls per member; if constexpr prunes dead type branches; std::define_static_string lifts the final JSON into static storage. Zero runtime allocation; zero reflection artefacts in the binary.

MCP server demo

A four-tool MCP server (built on top of mcp-cpp or your own HTTP shim). This is a sketch: there is no mcp.hpp and no llm::mcp::server, and list_flights and cancel are elided like the other declarations (Weather, FlightBooking and their argument structs).

#include <reflect_llmschema/mcp.hpp>  // sketch: there is no such header

struct [[=llm::description("Fetch weather for a city.")]] WeatherArgs {
    std::string city;
    std::string country_code = "US";
};
Weather get_weather(WeatherArgs);

struct [[=llm::description("Book a flight between two airports.")]] FlightArgs {
    std::string from;
    std::string to;
    std::chrono::year_month_day date;
};
FlightBooking book_flight(FlightArgs);

int main() {
    llm::mcp::server s;
    s.register_tool<&get_weather,  "get_weather">();
    s.register_tool<&book_flight,  "book_flight">();
    s.register_tool<&list_flights, "list_flights">();  // declaration elided
    s.register_tool<&cancel,       "cancel">();        // declaration elided
    s.serve(8080);
}

Four lines would register four tools. Each registration would:

  1. Emits the JSON-Schema at compile time for the tool’s parameter struct.
  2. Registers a runtime dispatch that deserialises the incoming JSON into the typed struct (the technique is the one in post 10; there is no published reflect_json library), calls the function, serialises the return value back.
  3. Publishes on the /tools MCP endpoint.

Why this was impossible before 2026

Runtime reflection (Java, C#, Python) can walk a function’s parameters and emit the schema at startup, carrying reflective metadata in the binary.

Code generation (Rust, protobuf) can do it at compile time, via a derive macro that sees the token tree and emits a second compilation unit.

C++26 reflection does both cheaper than either:

  • Cheaper than runtime reflection: no metadata carried at runtime; the JSON string is a literal, the dispatch is a direct call.
  • Cheaper than proc_macros / codegen: single compilation, no two-phase build, no generated files to check into your repo.

Comparison with current C++ practice

Without reflection, you’d use one of:

  • Hand-written JSON literals: error-prone; every renaming is a silent bug.
  • nlohmann/json builders: runtime construction of the schema at startup.
  • Schema-first codegen (OpenAPI, JSON Schema → C++): external tool, two-phase build, generated files.

reflect_llmschema removes all three. Your C++ declaration is the schema.

What exists today

github.com/wrocpp/reflect_llmschema is a placeholder: a README with no code, licence or release. The embedded demo is the only code. It has llm::description on a struct, and llm::openai_tool_json<Args>("name"), which prints the OpenAI function-calling JSON for bool, integer, floating-point, string and nested-struct fields. It has no field descriptions, format, default_value, range, pattern or enum support, no anthropic_tool or mcp_tool output, no MCP server, and no std::optional handling (every field lands in required).

A library could go further. This list is what the post sketched, and none of it is built:

  • llm::openai_tool<Fn, "name">(): OpenAI function-calling JSON.
  • llm::anthropic_tool<Fn, "name">(): Anthropic tool_use JSON.
  • llm::mcp_tool<Fn, "name">(): MCP tools/list entry.
  • llm::mcp::server: an MCP server class that handles JSON-RPC and tool dispatch.
  • Annotations: description, format, default_value, range, pattern, enum_values.

Open questions for a first version: streaming tool responses, ref schemas for shared sub-types, and parameter validation with std::expected errors.

Next in the series, and the example sources

Questions / corrections / your own MCP server use case: drop in Discussions or on Slack.