Code generation MCP
View MarkdownStatus: Stable Package:
dev.vertique.codegen.mcpArtifact:vertique-codegen-mcpDepends on:vertique-codegen-core,vertique-mcp-core,vertique-core,vertique-security-core,vertique-input-processing,com.palantir.javapoet:javapoetAPI reference: javadoc.io
vertique-codegen-mcp is the annotation processor that turns every @McpTool-annotated method into
generated Model Context Protocol dispatch source: one package-private invoker per tool and one
explicit GeneratedMcpToolsModule Dagger module that multibinds them. Tool dispatch therefore needs
no classpath scanning and no reflective invocation — the generated invoker calls the application
method directly.
The processor is also the module’s validation gate. A tool declaration the generated code could not call directly, name unambiguously, or represent honestly in JSON is a compile error, not a degraded runtime binding. There is no reflective fallback to fall back to.
When To Use It
Section titled “When To Use It”Add this processor whenever an application publishes MCP tools with @McpTool. It pairs with
vertique-mcp-core (the annotations and descriptor/invoker contracts it reads and the generated
source implements) and vertique-mcp-server (which injects the generated multibindings and composes
the runtime).
This processor is part of the vertique-codegen-all facade: it is activated automatically by a
vertique-codegen-all dependency and, transitively, by vertique-app-parent. An explicit
vertique-codegen-mcp entry in annotationProcessorPaths is only needed in an off-parent setup
that does not depend on vertique-codegen-all — for example a module that assembles its
annotation-processor path by hand (the version still comes from vertique-bom). Without the
processor on the path, whether via the facade or an explicit entry, no MCP source is generated and
the application composes with an empty tool set.
The processor emits no runtime Dagger module of its own and contributes nothing to the application classpath beyond its generated source.
Core Concepts
Section titled “Core Concepts”One invoker per tool, one module for the registry
Section titled “One invoker per tool, one module for the registry”For every accepted tool the processor writes a package-private
<DeclaringType>_<method>_McpToolInvoker. The invoker is Dagger-constructed with the application’s
tool bean, builds its immutable McpToolDescriptor once during composition, and calls the tool
method directly.
All invokers are then bound by one generated module:
@Generated("dev.vertique.codegen.mcp.McpToolProcessor")@Modulepublic abstract class GeneratedMcpToolsModule {
@Provides @IntoSet static McpToolInvoker weatherTools_lookup_invoker(WeatherTools_lookup_McpToolInvoker invoker) { return invoker; }
@Provides @IntoSet static McpToolDescriptor weatherTools_lookup_descriptor(WeatherTools_lookup_McpToolInvoker invoker) { return invoker.descriptor(); }}Install GeneratedMcpToolsModule in the application’s Dagger component. This module is the whole
tool registry — a tool that is not bound here does not exist at runtime. The descriptor binding is
derived from the invoker instance rather than rebuilt, so the published descriptor set and the
dispatchable invoker set cannot drift apart.
Generated invoker constructor threads an optional Bean Validation Validator
Section titled “Generated invoker constructor threads an optional Bean Validation Validator”Every generated invoker’s @Inject constructor additionally accepts
java.util.Optional<jakarta.validation.Validator>, resolved from the application’s Dagger graph
through vertique-mcp-server’s McpServerModule.@BindsOptionalOf Validator — no source change is
required in GeneratedMcpToolsModule itself, since Dagger resolves the parameter directly when
constructing the invoker, the same way it already resolves InputObjectProcessor. prepare()’s
stage 4 Bean Validation call routes through it: McpBeanValidation.validate(input, validator), which
falls back to vertique-mcp-core’s zero-config static default when the optional binding is absent —
so a composition that binds no Validator at all sees byte-for-byte unchanged behavior.
Where the generated module lands
Section titled “Where the generated module lands”The module is emitted into the longest common package prefix of every tool’s declaring type. Because the generated invokers are package-private and always land in their declaring type’s own package, every tool’s declaring type must live in one single package — the module’s package. Tools spread across sibling packages cannot compile, and no output-package override changes that:
<compilerArgs> <arg>-Avertique.codegen.package=com.example.app.mcp</arg></compilerArgs>The override only pins which single package the module (and therefore every tool) must use. A tool outside the resolved package is a compile error that names this option, rather than a generated module that does not compile.
Deterministic, all-or-nothing emission
Section titled “Deterministic, all-or-nothing emission”Tools are grouped by declaring type and ordered by declaring type and method name, so repeated builds of the same inputs produce byte-identical source. When any validation diagnostic is reported the processor emits nothing; a package-resolution failure discovered during module emission can leave already-written invoker sources behind, but the compilation still fails, so a partial registry can never bind.
Effective JSON profile is resolved at compile time
Section titled “Effective JSON profile is resolved at compile time”The processor resolves the tool’s @JsonProfile method-over-type, validates and normalizes the id
through JsonProfileId, and emits the normalized id as a nullable typed JsonProfileId literal
(JsonProfileId.of("…")) on the invoker — not a raw string. A blank annotation value is a compile
error. Resolving the remaining tail of the precedence chain — the MCP boundary default, then the global
default, floored at the shared managed-edge effective profile (json.jsonProfile, or the reserved
vertique id when unset) — and rejecting an unknown id belongs to composition in
vertique-mcp-server.
Generated parameter carriers and metadata
Section titled “Generated parameter carriers and metadata”For every parameterized tool the processor emits:
- a private nested
Inputrecord whose components are named positionally —argument0,argument1, … — so a carrier component can never collide with another regardless of the declared protocol names; @JsonProperty(protocolName)on each component, so the wire’s declared argument name (which may be a Java keyword, contain hyphens, or otherwise not be a legal identifier) is preserved exactly without deriving a Java identifier from it;- each parameter’s resolved final effective input-policy chain as normalized base
@Canonicalize/@Sanitizeannotations on the component (never@Skip*), resolved at compile time by the package-privateMcpInputPolicyResolverthrough the sharedElementInvocationPoliciesadapter — route-level chains for the tool method over its declaring type, then each parameter’s own override; - a position-stable
List<McpToolParameterMetadata>pairing each component name with its external protocol name and description.
McpInputPolicyResolver derives policies through the same vertique-input-processing adapter the
JAX-RS codegen resolver uses, so the two transports cannot drift apart. The derivation is hierarchy-
and composed-annotation-aware: a policy declared only on a superclass, an implemented interface, or
reached through a meta-annotated composed annotation is honored exactly as the reflective runtime
honors it. An inherited conflict — the merged view of an element (its own declaration, its
superclasses, and its interfaces) declaring both an additive policy and its @Skip* counterpart — is
a compile error whose message names both declaration sites; a route-level conflict is reported at the
method, and a parameter-level conflict is reported at the parameter.
A handler’s return type is adapted onto McpToolResult, never coerced to a partial shape
Section titled “A handler’s return type is adapted onto McpToolResult, never coerced to a partial shape”Exactly four handler return shapes are accepted: a plain T, a Future<T>, a handler-authored
McpToolResult<T>, and a Future<McpToolResult<T>>. Every other declared return type — void,
Future<Void>, a raw or wildcard result, an SDK or Reactor type, RoutingContext — is a compile
error, so the generated invoker always has an honest adaptation to emit.
The generated invoke() always returns Future<McpToolResult<?>>:
| Declared shape | Generated adaptation |
|---|---|
T |
Future.succeededFuture(McpToolResult.text(value)) for a String, or McpToolResult.structured(value) for any other type |
Future<T> |
The same text/structured choice, applied via .map(...) on the resolved value; a failed Future<T> propagates its failure unchanged — no McpToolResult is ever built |
McpToolResult<T> |
Wrapped in an already-succeeded Future, passed through exactly as the handler built it, including isError=true |
Future<McpToolResult<T>> |
Passed through exactly as returned; a failed future again propagates its failure unchanged |
McpToolResult carries no partial or intermediate result state, and the emitter never fabricates
one: a settled invocation produces a complete McpToolResult or the future fails — there is no
third outcome for the caller to adapt.
Access mode is derived from the annotations REST already uses
Section titled “Access mode is derived from the annotations REST already uses”@PermitAll, @DenyAll, @RolesAllowed, and @RequiresAction resolve over the same ordered source
tiers REST uses: the concrete method, overridden superclass methods nearest-first, the unique
interface methods it overrides, the concrete type, superclass types nearest-first, then the type’s
interfaces. The first tier that declares a policy wins, so a method-level source always overrides a
type-level one, and several interface candidates in one tier must agree.
| Resolved annotations | Published McpAccessMode |
|---|---|
| none | PERMIT_ALL (no roles, no action) |
@PermitAll |
PERMIT_ALL |
@DenyAll |
DENY_ALL |
@RolesAllowed and/or @RequiresAction |
RESTRICTED, composed with AND when both are present |
@RequiresAction conflicts with @PermitAll and @DenyAll, exactly as in REST. Inline
@Authorized declarations are unsupported on MCP tools and are rejected, including when inherited.
A typed policy replaces inline security and publishes a fail-closed descriptor
Section titled “A typed policy replaces inline security and publishes a fail-closed descriptor”A tool can instead reference a typed access policy with @RequiresPolicy(Policy.class) on the tool
method or its declaring type. The processor selects the policy before it applies any rule above. It
collects declarations from the tool’s declaring type — the method, the interface and superclass
methods that correspond to it after generic substitution, and the whole type hierarchy — and a method
policy replaces the entire type policy. A policy’s own @Authorized, scopes, roles and action are
part of that policy and are accepted; an inline @Authorized on the tool stays rejected.
For a typed tool the model carries DENY_ALL with no roles and no action, and the generated invoker
overrides McpToolInvoker#accessPolicy() to return Optional.of(Policy.class) while publishing that
DENY_ALL descriptor. A runtime that ignores the hook therefore refuses the tool. An inline-only
tool emits no hook and its descriptor is unchanged.
Each of these is one compile error naming the tool and the policy:
- Distinct policy references in the method set, or in the type set, including parent/child and diamond conflicts. The same policy repeated is accepted.
- A policy beside any inline
@RolesAllowed,@RequiresAction,@PermitAll,@DenyAllor@Authorizedon the method or type path. - A policy that is not a public non-generic interface extending only
AccessPolicy, declares members, has no requirement, has a blank role or scope or a malformed action, references another policy, or carries a security-scheme annotation or an observable composed security annotation. - A policy the generated invoker cannot name: it, or a type enclosing it, is private, or is not public and outside the tool’s package.
Overloads, static or private methods and package-private methods of another package that merely look like the tool method are not part of its declaration set and are ignored, never rejected. A conflict in the type set is reported even when a method policy would replace the type policy.
Validation Boundaries
Section titled “Validation Boundaries”Validation is fail-fast per declaration: the first boundary a tool violates produces exactly one targeted diagnostic and no model, so a rejected tool never produces a second, derived complaint.
| Boundary | What is required |
|---|---|
| Direct invocability | The tool method is public, concrete, and an instance method on a Dagger-managed type with exactly one @Inject constructor |
| Protocol metadata | Tool names are unique across the compilation and match [A-Za-z0-9_.-]{1,128}; the description is non-blank and bounded; a blank title is omitted |
| Honest type contracts | No raw, wildcard, type-variable, or unresolved type in a parameter or result; no void result; no input member without a JSON schema representation |
| Effective JSON profile | Resolved method-over-type; a blank id is rejected at compile time rather than surfacing as an unresolvable mapper during composition |
| Input-policy conflicts | A @Canonicalize/@Sanitize declaration and its @Skip* counterpart on the same element (method, declaring type, or parameter) is a compile error |
Output-schema synthesis stays a composition-time concern: a result type that is structurally valid here but that the shared runtime schema generator cannot synthesize fails startup, before the router is mounted.
Common Mistakes
Section titled “Common Mistakes”Expecting a reflective fallback
Section titled “Expecting a reflective fallback”There is none. If the generated invoker cannot call the method directly — a non-public method, an abstract or static method, a declaring type Dagger cannot construct — the build fails. That is the design: an MCP tool either dispatches directly or does not ship.
Tools spread across unrelated packages
Section titled “Tools spread across unrelated packages”Generated invokers are package-private and always land in their declaring type’s package, so every
tool’s declaring type must live in the one generated-module package. Keep tool types in a single
package; -Avertique.codegen.package only pins which package that is.
Forgetting to install GeneratedMcpToolsModule
Section titled “Forgetting to install GeneratedMcpToolsModule”The processor writes the module but cannot install it. Without the module in the application’s Dagger
component, the server composes with an empty tool set and no error is reported by the compiler —
vertique-mcp-server logs one unconditional WARN naming both this cause and the alternative
(vertique-codegen-mcp absent from the processor path in a pre-facade, off-parent setup).
Assuming the carrier component name tells you the protocol name
Section titled “Assuming the carrier component name tells you the protocol name”It does not, by design. argument0, argument1, … is a positional Java identifier with no
relationship to the declared @McpToolParam name; the protocol name lives only in
@JsonProperty(...) and in the emitted McpToolParameterMetadata list. Do not pattern-match on
component names in generated-source tooling.
Key Classes
Section titled “Key Classes”McpToolProcessor
Section titled “McpToolProcessor”The annotation processor entry point (META-INF/services/javax.annotation.processing.Processor).
It discovers @McpTool methods, validates declarations at compile time, emits one package-private
invoker per accepted tool, and emits GeneratedMcpToolsModule to multibind invokers and
descriptors. It always returns false from process so peer processors see unmodified elements.
Per-type opt-out is @NoAutoWire; -Avertique.codegen.package pins the generated module package
(and therefore the single package every tool declaring type must live in).
Module Dagger Bindings
Section titled “Module Dagger Bindings”None at processor scope. The processor’s output — GeneratedMcpToolsModule — is the module the
application installs.
Dependencies
Section titled “Dependencies”| Artifact | Scope | Purpose |
|---|---|---|
vertique-codegen-core |
compile | CodegenContext, TypeResolver, AnnotationMirrors, Diagnostics, and the generated-name helpers |
vertique-mcp-core |
compile | @McpTool / @McpToolParam, and the McpToolDescriptor, McpToolAccess, McpToolInvoker, McpPreparedToolCall contracts the generated source implements |
vertique-core |
compile | @JsonProfile and JsonProfileId for effective-profile resolution and normalization |
vertique-security-core |
compile | @RequiresAction / ActionRef, from which the tool’s access mode is derived, and @RequiresPolicy / AccessPolicy for typed policies |
vertique-input-processing |
compile | dev.vertique.input.processing.apt.ElementInvocationPolicies and InvocationPolicyConflictException, the shared compile-time derivation of route and parameter policy chains; the generated invoker’s EffectiveInputPolicies.NONE literal is still addressed by name only (a JavaPoet ClassName), not by import |
com.palantir.javapoet:javapoet |
compile | Source emission |
Test-only dependencies: vertique-codegen-test, jakarta.inject-api, jakarta.annotation-api.