reflect_llmschema: C++ functions to LLM tool-use schemas, at compile time
Library repo: github.com/wrocpp/reflect_llmschema · v0.1.0 · MIT. Installs via
find_package(reflect_llmschema)(vcpkg recipe attached below).
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 reflect_llmschema:
#include <reflect_llmschema/schema.hpp>
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">());
}
Output: the exact JSON above, computed at compile time, baked into .rodata. Rename a field; the schema follows. Add a parameter; the schema grows. Drift becomes impossible.
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’s ~50 lines of if constexpr ladders plus template for recursion. The whole library is under 500 lines.
The core walk
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):
#include <reflect_llmschema/mcp.hpp>
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">();
s.register_tool<&cancel, "cancel">();
s.serve(8080);
}
Four lines register four tools. Each registration:
- Emits the JSON-Schema at compile time for the tool’s parameter struct.
- Registers a runtime dispatch that deserialises the incoming JSON (using the
reflect_jsonlibrary from post 10) into the typed struct, calls the function, serialises the return value back. - Publishes on the
/toolsMCP 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 the library ships
Under github.com/wrocpp/reflect_llmschema:
llm::openai_tool<Fn, "name">(): OpenAI function-calling JSON.llm::anthropic_tool<Fn, "name">(): Anthropic tool_use JSON.llm::mcp_tool<Fn, "name">(): MCPtools/listentry.llm::mcp::server: drop-in MCP server class that handles JSON-RPC + tool dispatch.- Annotations:
description,format,default_value,range,pattern,enum_values.
Status: v0.1.0 covers the MVP scope above. v0.2 roadmap: streaming tool-responses, ref schemas for shared sub-types, parameter validation with std::expected errors.
Next in the series, and the example sources
- Post 20: reflect_arbitrary: property-based test generators from types.
- The example file exercises the type-mapping walk; see
posts/19-reflect-llmschema/examples/in thecpp26-reflection-examplesrepo.
Questions / corrections / your own MCP server use case: drop in Discussions or on Slack.