ADR-0016 — Victools jsonschema-generator for tool input/output schemas¶
- Status: Accepted
- Date: 2025-07-06
Context¶
A tool's tools/list entry includes an inputSchema (and optionally an
outputSchema) that describes the JSON shape of the call's arguments
object. Clients — including LLM clients — read that schema to decide
how to construct calls; the schema is not optional dressing.
Mocapi discovers tool methods reflectively (ADR-0010). The natural source of truth for the input schema is the method signature itself: the parameter types, names, generic type arguments, and any annotations already carry enough information to generate a JSON Schema. Hand-written schemas alongside Java method signatures double the maintenance burden and drift the moment a parameter is added or renamed.
Unlike elicitation (ADR-0015), the tool input-schema vocabulary in MCP is the full JSON Schema draft — nesting, references, polymorphism, all of it. Building a schema generator from scratch to cover that surface is unjustifiable when a mature library exists.
The Victools jsonschema-generator library is the standard Java
solution: it walks types, supports records and Jackson annotations
out of the box, has well-defined extension points (modules, custom
definition providers), and produces draft-2020-12 output (matching
what MCP clients expect).
Decision¶
Tool input and output schemas are generated at startup from the method
signature by the Victools jsonschema-generator library
(com.github.victools:jsonschema-generator).
Generation runs once per tool at handler-build time during
@PostConstruct — alongside annotation discovery and customizer
attachment. The resulting schema is closed over by the handler; the
hot path does not invoke the generator.
Record components are required-by-default. When a tool parameter
(or an @McpToolParams record's component) is a non-nullable record
field, the generated schema marks it required without the user having
to opt in via Jackson @JsonProperty(required = true). Java records
have non-null component semantics by construction; the schema reflects
that. Nullable components (Optional<T> or types annotated to indicate
nullability) remain non-required.
Configurable via the customizer SPI. Users who need to override
generator behavior — additional modules, custom definition providers,
schema-version override — register a SchemaGeneratorConfigBuilder
customization through the existing customizer pattern; the autoconfig
exposes the builder under @ConditionalOnMissingBean so a user @Bean
replaces the default.
Output schemas are opt-in. Tools with a non-trivial return shape
can register an outputSchema for clients that validate responses;
the framework generates it from the return type the same way. Output
schema validation at runtime is also opt-in (it has a real cost, and
not every deployment wants to validate the server's own outputs).
Structured output may be any JSON value (MCP 2026-07-28). The spec
widened structuredContent from a JSON object to any JSON value
(object, array, string, number, boolean, or null), so any tool return
type that is not void, CallToolResult, or a CharSequence is mapped
to structuredContent of whatever shape it serializes to — a record or
Map becomes an object, a List/array becomes an array, a primitive
becomes a scalar. The derived schema is advertised as the outputSchema
when it carries a concrete type; an untyped empty schema (e.g. raw
Object) is mapped structurally but advertises no schema. Optional<T>
is the one rejected case: its element type is erased on the return
signature, so no schema can be derived — return the value directly or a
CallToolResult. (Earlier mocapi enforced the 2025-11-25 object-only
rule; this relaxation finished a migration the model already reflected,
structuredContent being typed JsonNode.) A single ContentBlock return
(ImageContent, AudioContent, ResourceLink, EmbeddedResource,
TextContent) is a separate ergonomic shortcut: it is wrapped as the sole
content item with no structured content or schema, sparing the author a
hand-built CallToolResult for the common single-non-text-block case.
Consequences¶
What this buys us. Adding a tool parameter automatically updates
the wire schema — no hand-edited JSON Schema sitting next to a Java
signature drifting out of sync. Records get sensible required-field
semantics for free. Generator behavior is overridable for the rare
deployment that needs custom type mappings (e.g., a project-specific
Money type that should serialize as a string with a known pattern).
Schema generation cost is paid once at startup, not per call.
Costs. Victools is a transitive dependency of mocapi-server. The
generator's default behavior — particularly around polymorphism and
generic type arguments — sometimes needs nudging via custom modules
for advanced types; users with exotic signatures may need to write a
small customizer. Schema generation runs at startup for every
discovered tool, adding tens to low-hundreds of milliseconds for tool-
heavy applications (still small relative to the rest of Spring Boot
startup).
Non-goals. Mocapi does not auto-generate schemas for prompts or
resources — prompts use argument descriptors, resources use URI
templates with separate variable metadata. The generator is scoped to
the tool path, where the JSON-Schema-shaped inputSchema /
outputSchema fields live. Elicitation does not use Victools at all
(ADR-0015).
Code anchors: mocapi-server/.../tools/DefaultMethodSchemaGenerator.java;
return-type classification and schema advertisement in
mocapi-server/.../tools/CallToolHandlers.java (createResultMapper);
structured mapping in mocapi-server/.../tools/StructuredResultMapper.java;
single-block mapping in mocapi-server/.../tools/ContentBlockResultMapper.java.
Required-by-default for record components landed in commit fe420b43 and
shipped in 0.17.0.