tool_schema_generator
A Dart code generator that automatically produces provider-compatible tool schemas for Large Language Models from annotated Dart functions — with a unified runtime registry for dispatch, validation, and multi-provider encoding.
Why
When building LLM agents, every provider (OpenAI, Anthropic, Gemini) expects a different JSON envelope to describe callable tools. Writing and maintaining these maps by hand — across multiple providers — is tedious and error-prone.
tool_schema_generator solves this with a simple model:
- Annotate your Dart functions with
@Tool(). - Generate —
build_runnerproduces atoolRegistrywith provider-shaped schemas and a validated dispatcher. - Use — pass
tools.schemas.format(.gemini)to your LLM and calltools(name, args)when it responds.
No hand-written JSON. No duplicated schemas. One source of truth.
Features
- Zero boilerplate — names, types, nullability, and doc comments are all inferred from Dart syntax.
- Multi-provider encoding — one annotation, four provider shapes: OpenAI, Anthropic, Gemini, and MCP.
- One encoding verb —
format(SchemaFormat)everywhere, reading naturally with Dart's enum dot-shorthand:tools.schemas.format(.gemini). - Provider-agnostic strict mode — opt into closed, fully-required schemas with
@Tool(strict: true). - Type-safe dispatch — the generated registry validates every argument and routes calls to the right Dart closure.
- Raw JSON as a first-class citizen — hand-written
Map<String, Object?>schemas can be passed directly toToolRegistry; no wrapper required. - Runtime composition — registries can be extended at runtime with
extend(), mixing generated and ad-hoc tools. - Runtime injection — hide session parameters (user IDs, locale, etc.) from the LLM schema with
@Inject(). - Full type support —
String,int,double,bool,List<T>,Map<String, Object?>,enums, and custom nested classes. source_gencompatible — plays nicely alongsidejson_serializableand other generators.
Installation
dependencies:
tool_schema_generator: ^1.2.0
dev_dependencies:
build_runner: ^2.15.0
Quick Start
1. Annotate your functions
// lib/tools.dart
import 'package:tool_schema_generator/tool_schema_generator.dart';
part 'tools.g.dart';
/// Sends an email to a specific recipient.
@Tool()
Future<void> sendEmail(
@Describe('Recipient email address') String to,
@Describe('Subject line') String subject, {
@Describe('Body content') required String body,
bool isHtml = false,
}) async {
// your implementation
}
2. Run the generator
dart run build_runner build
3. Use the generated tools collection
import 'tools.dart';
// ── Pass schemas to any LLM (Gemini, Claude, OpenAI, MCP) ───────────────────
final response = await gemini.generate(
prompt: 'Send an email to hello@example.com saying Hi!',
tools: tools.schemas.format(.gemini), // Gemini FunctionDeclarations
// tools: tools.schemas.format(.anthropic), // Anthropic tool shapes
// tools: tools.schemas.format(.openAi), // OpenAI function shapes
// tools: tools.schemas.format(.mcp), // Model Context Protocol shapes
);
// ── Dispatch the model's tool call ──────────────────────────────────────────
for (final call in response.toolCalls) {
final value = await tools(call.name, call.arguments);
print('Tool returned: $value');
}
Core API
tools (or toolRegistry)
The generated collection implements Iterable<JsonObject> / Iterable<ToolDefinition>:
Multi-Format Schema Encoding
One canonical batch formatter covers every provider — including MCP:
tools.schemas.format(.gemini); // All tools as Gemini FunctionDeclarations
tools.schemas.format(.anthropic); // All tools for Claude (`input_schema`)
tools.schemas.format(.openAi); // All tools, OpenAI Draft 2020-12 envelope
tools.schemas.format(.mcp); // All tools for Model Context Protocol
Results are memoized per format; tools.schemas is itself cached on the registry.
Individual Tool Schema Formatting
Each generated getter (e.g. tools.sendEmail) returns a ToolDefinition with the same
canonical format(...) verb:
// Format for specific providers
tools.sendEmail.format(.gemini);
tools.sendEmail.format(.anthropic);
tools.sendEmail.format(.mcp);
tools.sendEmail.schema.parameters; // Inner parameters schema only
Dynamic Filtering & Composition
// Filter tools dynamically:
final publicGeminiTools = tools
.where((t) => !t.name.startsWith('admin_'))
.map((t) => t.format(.gemini))
.toList();
// Combine registries with operator +
final fullSuite = coreTools + pluginTools;
Legacy toolRegistry.encode({String? name, SchemaFormat format}) (deprecated)
The pre-1.2 encoding method still works but is deprecated in favor of the paths above.
Unlike schemas.format, it filters by each tool's declared formats: list.
| Call | Returns |
|---|---|
encode() |
All tools, OpenAI format |
encode(format: SchemaFormat.gemini) |
All tools, Gemini format |
encode(name: 'search') |
1-element list, that tool, OpenAI format |
encode(name: 'search', format: SchemaFormat.anthropic) |
1-element list, that tool, Anthropic format |
// Spread a hand-crafted tool alongside generated ones
final allTools = [
...toolRegistry.encode(), // prefer tools.schemas.format(.openAi)
thinkTool, // a raw JsonObject
];
// Single tool by name
final justSearch = toolRegistry['search']!.format(.openAi);
// or, using the generated constant for that function:
const justSearchSchema = searchToolSchema;
toolRegistry.encoded (deprecated)
Convenience getter equivalent to encode() — use tools.schemas.format(.openAi) instead.
Named getters
The generated subclass exposes a strongly-typed getter per tool, returning a ToolDefinition (which is itself a Map<String, Object?>):
// Equivalent to toolRegistry['sendEmail']!.format(.openAi)
final schema = toolRegistry.sendEmail; // ToolDefinition (OpenAI shape)
toolRegistry.call(name, args)
Dispatches a tool call. Validates all arguments, calls the Dart function, and returns the raw result.
final value = await toolRegistry.call(call.name, call.arguments);
Throws typed exceptions on failure (see Error Handling).
toolRegistry.callOrNull(name, args)
Like call, but returns null instead of throwing when the name is not registered. Useful in multi-registry fan-out patterns.
toolRegistry.extend(tools)
Returns a new ToolRegistry that merges the current tools with additionalTools. On name collision, the later entry wins.
// Mix generated tools with a raw JSON schema at runtime
final extendedRegistry = toolRegistry.extend([
{'type': 'function', 'function': {'name': 'thinkTool', 'parameters': {...}}},
]);
// Compose two generated registries
final combined = registryA.extend(registryB);
Annotations
@Tool()
Marks a top-level function as an LLM-callable tool. The annotation is targeted to top-level functions, so invalid placements are reported by the analyzer and by the generator.
@Tool(
name: 'custom_name', // optional — defaults to Dart function name
description: 'Override ...', // optional — defaults to doc comment
strict: true, // optional — defaults to false
formats: [ // optional — defaults to all four
SchemaFormat.openAi,
SchemaFormat.anthropic,
SchemaFormat.gemini,
SchemaFormat.mcp,
],
)
strict: true is additive and opt-in. Existing @Tool() declarations keep
their current non-strict behavior.
@Describe('...')
Adds a description to a parameter. Without it, the parameter still appears in the schema but has no "description" field.
@Tool()
void search(
@Describe('The search query string') String query,
@Describe('Max number of results, 1–50') int limit,
) {}
@Inject()
Hides a named parameter from the schema sent to the LLM. The value is still available to the handler via the args map, so you can inject session context at call time.
@Tool()
Future<void> createTask(
@Describe('Task title') String title, {
@Inject() String? userId, // hidden from schema, merged before dispatch
@Inject() String locale = 'en',
}) async { ... }
// Dispatch — merge runtime context before calling
await toolRegistry.call(call.name, {
...call.arguments,
'userId': session.userId,
'locale': request.locale,
});
Rules for @Inject():
- Only on named parameters.
- Must be optional — nullable or have a Dart default value.
- Cannot be
required.
Strict Mode
Strict mode is useful when you want the model to follow the tool schema as closely as the provider allows. Enable it per tool:
@Tool(strict: true)
Future<void> createTask({
required String title,
String? notes,
}) async {}
For strict tools, the generated parameter schema is transformed before it is emitted:
- Every visible parameter is listed in
required, including nullable and defaulted parameters. - Nullable schemas use JSON Schema union types such as
{"type": ["string", "null"]}instead of"nullable": true. - Every object schema, including nested custom classes, gets
"additionalProperties": false. - OpenAI and Anthropic envelopes include
"strict": true. - Gemini receives the same strict-shaped parameter schema, without an extra provider-specific strict flag.
Strict mode intentionally rejects Dart types that cannot be represented as a
closed JSON Schema during generation. This includes dynamic, void,
free-form Map<...> parameters, raw lists without item types, recursive object
graphs, and nested fields with those shapes. Use concrete typed classes when a
tool needs strict behavior.
Multi-Provider Encoding
Provider schema shapes
SchemaFormat controls which envelope is emitted:
SchemaFormat |
Envelope shape |
|---|---|
openAi |
{"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}} |
anthropic |
{"name": ..., "description": ..., "input_schema": ...} |
gemini |
{"name": ..., "description": ..., "parameters": ...} |
mcp |
{"name": ..., "description": ..., "inputSchema": ...} |
All four share the same provider-agnostic JSON Schema for the parameters — only the outer envelope changes. Encode any of them with one verb:
tools.schemas.format(.openAi); // or .anthropic / .gemini / .mcp
No implicit default:
format(...)always requires an explicitSchemaFormat— nothing silently assumes OpenAI. The only defaulted member is the deprecatedToolDefinition.encode([format])shim (defaults toopenAi).
When @Tool(strict: true) is used, OpenAI and Anthropic encodings also include
their strict flag. The canonical parameter schema remains provider-neutral.
Restricting a tool to specific providers
// This tool only appears when encoding for Anthropic
@Tool(formats: [SchemaFormat.anthropic])
Future<String> claudeOnlySearch(String query) async => '...';
// toolRegistry.encode(format:) (deprecated) filters by the declared formats list:
toolRegistry.encode(format: SchemaFormat.openAi); // does not include claudeOnlySearch
toolRegistry.encode(format: SchemaFormat.anthropic); // includes it
// schemas.format(...) always formats every registered definition:
tools.schemas.format(.anthropic); // includes claudeOnlySearch
What
formats:does NOT do: it never sets a per-tool default output and never blocks an encoding.format(SchemaFormat)is always explicit and always derives the requested envelope —claudeOnlySearch.format(.gemini)returns a valid Gemini shape. The declared list is metadata consumed by the deprecatedencode(format:)filter.
Type Support
The generator maps Dart types to JSON Schema types:
| Dart type | JSON Schema |
|---|---|
String |
{"type": "string"} |
int |
{"type": "integer"} |
double |
{"type": "number"} |
bool |
{"type": "boolean"} |
List<T> |
{"type": "array", "items": <schema for T>} |
Map<String, Object?> |
{"type": "object"} |
T? (nullable) |
non-strict schemas add "nullable": true; strict schemas use JSON Schema unions such as {"type": ["string", "null"]} |
enum Foo { a, b } |
{"type": "string", "enum": ["a", "b"]} |
Custom class Foo |
{"type": "object", "properties": {...}, "required": [...]} |
Enums
enum Priority { low, normal, high }
@Tool()
void setTaskPriority(Priority priority) {}
// → {"type": "string", "enum": ["low", "normal", "high"]}
The generated dispatcher also emits a _parseEnum<T> helper that validates
the raw string and throws InvalidToolArgumentException on unknown values.
Nested objects
The generator inspects the class's primary constructor to build the nested schema. Required constructor parameters become JSON Schema required entries.
class GeoLocation {
final double latitude;
final double longitude;
GeoLocation({required this.latitude, required this.longitude});
}
@Tool()
void findNearbyPlaces(
@Describe('The center point') GeoLocation location,
double radiusKm,
) {}
// → {"type": "object", "properties": {"latitude": ..., "longitude": ...}, "required": ["latitude", "longitude"]}
Error Handling
All dispatch errors are subtypes of ToolCallException:
| Exception | When |
|---|---|
ToolNotFoundException |
No tool registered under that name |
MissingToolArgumentException |
A required argument was absent from the args map |
InvalidToolArgumentException |
Wrong type, wrong enum value, or list item type mismatch |
ToolExecutionException |
The tool function itself threw an unhandled exception |
try {
final value = await toolRegistry.call(call.name, call.arguments);
sendToModel(value.toString());
} on ToolNotFoundException catch (e) {
print('Unknown tool: ${e.name}. Available: ${e.available}');
} on MissingToolArgumentException catch (e) {
print('Missing field: ${e.field}');
} on InvalidToolArgumentException catch (e) {
print('Bad value for ${e.field}: expected ${e.expected}, got ${e.actual}');
} on ToolExecutionException catch (e) {
print('Tool crashed: ${e.error}');
}
Raw JSON as First-Class Input
ToolRegistry accepts raw Map<String, Object?> schemas directly — no wrapper needed. This is the primary path for integrating with external tool-calling standards (MCP, custom adapters, etc.).
// Both ToolDefinitions and raw maps are accepted
final registry = ToolRegistry([
...toolRegistry, // spread from generated registry
{'type': 'function', 'function': {'name': 'thinkTool', 'parameters': {...}}},
]);
// extend() also accepts raw maps
final extended = toolRegistry.extend([rawToolSchema]);
ToolDefinition.raw() normalises both envelope shapes:
{"type": "function", "function": {...}}(OpenAI-style) — used as-is.{"name": ..., "parameters": ...}(flat, Gemini/Anthropic-style) — wrapped automatically.- Flat Anthropic maps using
"input_schema"are recognized too.
After normalization a manual definition is indistinguishable from a generated one —
the same canonical format(...) verb applies:
final registry = ToolRegistry([
{'type': 'function', 'function': {'name': 'thinkTool', 'parameters': {...}}},
]);
registry['thinkTool']!.format(.gemini); // derive any provider on demand
registry.schemas.format(.mcp); // mixes generated + manual tools in one batch
Notes:
- Manual tools declare
formats: SchemaFormat.values, so they appear in every provider batch (including MCP). - Derived envelopes (
anthropic/gemini/mcp) rebuild strictly from the normalizedname/description/parametersSchema; provider-exotic keys from your raw map are preserved only in theopenAiview. - Without a handler,
registry.call(name, args)throwsToolExecutionException— useful when you only forward schemas and never execute locally.
Architecture & Internals
The generator uses a small compiler-style pipeline:
Dart elements
-> ToolParser
-> ToolSpec / ParameterSpec / SchemaSpec
-> optional strict schema transform
-> ToolSchemaGenerator / ToolDispatchEmitter
This keeps Dart analysis, schema transformation, and source rendering separate.
The public runtime API still centers on ToolRegistry, ToolDefinition, and
the generated toolRegistry.
SchemaSpec intermediate representation
TypeMapper maps Dart types into an internal SchemaSpec tree instead of
building schema source strings directly. ToolParser then wraps those schema
nodes into ToolSpec and ParameterSpec models. The emitters are the only
layers that turn those specs into generated Dart source.
That split matters because schema changes can now be tested and transformed as
data. Strict mode, for example, recursively walks the SchemaSpec tree to close
object schemas and expand required lists before code is emitted.
ToolDefinition extends MapView
A ToolDefinition is an OpenAI-compatible Map<String, Object?>. It stores one canonical representation internally (the OpenAI {"type": "function", ...} envelope) and derives the other provider shapes on demand in encode(SchemaFormat):
ToolDefinition
├── name, description, parametersSchema ← single source of truth
├── formats ← which providers support this tool
├── handler ← the Dart closure
└── encode(SchemaFormat) →
openAi → Map.unmodifiable(this) // re-uses the stored map
anthropic → {"name", "description", "input_schema": parametersSchema}
gemini → {"name", "description", "parameters": parametersSchema}
mcp → {"name", "description", "inputSchema": parametersSchema}
No pre-baked map per format. Provider shapes are derived lazily at encode time — one switch is the single source of truth for every provider, including MCP.
ToolRegistry extends IterableBase<JsonObject>
The registry's backing store is a single Map<String, ToolDefinition>. Because ToolRegistry extends IterableBase, its iterator yields ToolDefinition values directly — meaning for (final tool in toolRegistry) and spreading [...toolRegistry] both iterate the OpenAI-shaped maps (since ToolDefinition extends MapView).
ToolRegistry
├── _tools: Map<String, ToolDefinition>
├── iterator → _tools.values.iterator (each value IS a JsonObject)
├── encode({name?, format}) → List<JsonObject> ← unified API
├── call(name, args) → Future<Object?>
├── extend(tools) → new ToolRegistry
└── static getRequiredArg / getOptionalArg / ... ← shared argument helpers
What the generator emits
For each @Tool-annotated function, build_runner produces three things:
const <functionName>ParametersSchema— aMap<String, Object?>of the JSON Schema.const <functionName>ToolSchema— the full OpenAI envelope wrapping the parameters schema.- A
ToolDefinition(...)entry inside thetoolRegistryinitialiser, including theformatslist and a typedhandlerclosure.
The handler closure uses the static helpers on ToolRegistry for validation:
handler: (JsonObject args) async {
return getWeather(
ToolRegistry.getRequiredArg<String>(args, 'city'),
unit: _parseEnum(
TemperatureUnit.values,
ToolRegistry.getOptionalArg<String>(args, 'unit'),
'unit',
) ?? TemperatureUnit.celsius,
);
},
The generated subclass of ToolRegistry adds one named getter per tool:
final class _ToolRegistry extends ToolRegistry {
_ToolRegistry(super.tools);
ToolDefinition get getWeather => this['getWeather']!;
ToolDefinition get sendEmail => this['sendEmail']!;
}
What this architecture unlocks
The new parser/spec/emitter split is mainly an internal refactor, but it makes future schema work much safer:
- More focused unit tests against
ToolSpecandSchemaSpec, without fragile generated-string assertions. - Provider-specific schema adapters, if future providers need them, without duplicating Dart analysis logic.
- Additional JSON Schema constraints such as string formats, numeric ranges, list length limits, and richer object validation.
- Clearer build-time diagnostics for unsupported strict-mode shapes.
- Easier evolution of provider envelopes while keeping one canonical parameter schema.
Migrating to 1.2.0
These changes ship in 1.2.0; try them today with
tool_schema_generator: ^1.2.0-dev.
1.2.0 introduces the idiomatic tools collection, SchemaFormat.mcp, and a single
canonical encoding path: format(SchemaFormat) — reading naturally with Dart's enum
dot-shorthand (format(.gemini)) on SDK ≥3.10.
From toolRegistry to tools
Generated files now emit final tools = _ToolRegistry([...]); and keep
final toolRegistry = tools; as a backwards-compatible alias.
// Before (1.1.x)
final response = await llm.generate(tools: toolRegistry.encode());
// After
final response = await llm.generate(tools: tools.schemas.format(.openAi));
Encoding cheat sheet
| Task | 1.2.0 |
|---|---|
| All tools → any provider | tools.schemas.format(.gemini) / .anthropic / .openAi / .mcp |
| One tool → any provider | tools['getWeather']!.format(.gemini) |
| Parameters schema only | tools.getWeather.schema.parameters |
| Runtime name check | tools.hasTool('getWeather') |
Deprecated (removed in 2.0)
| Shipped ≤1.1.0 | Replacement |
|---|---|
toolRegistry.encode() / .encoded |
tools.schemas.format(.openAi) |
toolRegistry.encode(format: f) |
tools.schemas.format(f) |
toolRegistry.encode(name: 'x', ...) |
tools['x']!.format(...) |
toolRegistry.allSchemas |
tools.schemas.format(.openAi) |
toolDefinition.encode([f]) |
toolDefinition.format(f) |
Note: deprecated registry.encode(format:) is the only path that filters by each tool's
declared formats: list; schemas.format(...) formats every registered definition.
⚠️ SchemaFormat.mcp
Adding an enum constant breaks exhaustive switch statements over SchemaFormat;
add a case SchemaFormat.mcp: (or default) when upgrading. Minimum SDK is now 3.11.
Contributing
Contributions are welcome — please open a PR with a clear description of the problem and the change.
License
MIT
Libraries
- builder
- tool_schema_generator
- Annotations for automatic LLM tool schema generation.