GraalVM native-image hints for mocapi¶
Context¶
Exercised against cowork-connector-example (Spring Boot 4.0.5, Java 25) using the GraalVM tracing agent (-agentlib:native-image-agent). This document records what mocapi ships to make consuming apps native-image-ready and why — so the setup stays coherent as new model types land.
Reference companions in this family: ripcurl/docs/native-image-hints.md, methodical/docs/native-image-hints.md, codec/docs/native-image-hints.md.
Agent-captured surface (82 entries)¶
When the cowork-connector-example was run under the tracing agent with /mcp/** opened up and every tool/prompt exercised, mocapi surfaced:
mocapi.server.autoconfigure.*— 13 (auto-configs +@ConfigurationProperties)mocapi.server.*non-autoconfigure — 19 (framework service beans)mocapi.transport.http.*— 3 (controller, validator, auto-config)mocapi.prompts.spring.*— 2 (template factory + auto-config)mocapi.api.*annotations + SPI ifaces — 3 (@McpTool,@McpPrompt,PromptTemplateFactory)mocapi.server.exchange.McpExchange— 1mocapi.model.*wire types — 36
How coverage works¶
Two contributions in mocapi-server/src/main/resources/META-INF/spring/aot.factories:
org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\
com.callibrity.mocapi.server.autoconfigure.aot.MocapiServicesAotProcessor
org.springframework.aot.hint.RuntimeHintsRegistrar=\
com.callibrity.mocapi.server.autoconfigure.aot.MocapiRuntimeHints
MocapiServicesAotProcessor¶
For every Spring bean whose class declares at least one @McpTool, @McpPrompt, @McpResource, or @McpResourceTemplate method, walks its declared methods. On each annotated method:
ExecutableMode.INVOKEhint on the method itself (so the dispatcher's reflective call is legal in native).BindingReflectionHintson every parameter type (picks up enums, records, nested generics via Spring's registrar walker).BindingReflectionHintson the non-voidreturn type.
Non-matching beans are skipped. No-op for JIT builds.
This handles user code automatically — downstream apps don't write hints for their own result records, arg records, or enums. The cowork example's HelloResult, TodoItem, ListTodosResponse, etc. all get covered through this processor.
MocapiRuntimeHints¶
Registers binding hints for the mocapi-owned types that cross a Jackson codec boundary without appearing in a @...Method signature:
- Explicit non-model registrations — types Jackson serializes at runtime that live outside
mocapi-model, so the package scan below does not reach them: McpExchange— the per-request protocol context record bound during dispatch.RequestStatePayload— the MRTRrequestStatetoken payload.RequestStateCodecserializes it into the opaque, AES-256-GCM-encryptedrequestStatestring and reads it back on replay; itsResponseLedgerEntry→ElicitResultgraph is pulled in transitively. It lives in...server.mrtr, notmocapi-model, because the spec treatsrequestStateas an opaque server-owned string (ADR-0021) — so it is deliberately not a wire type, yet still needs a hint or native-image elicitation replay breaks.- Every class in
com.callibrity.mocapi.model(and any future subpackage) — scanned at AOT build time via Spring'sClassPathScanningCandidateComponentProvider. Covers tool/prompt/resource results (CallToolResult,GetPromptResult,ListToolsResult, …), descriptors (Tool,Prompt,Resource), sealed hierarchies (ContentBlock,ResourceContents), enums (Role,LoggingLevel), and arrays (PromptArgument[],Tool[],Resource[]) — ~92 types, no enumeration required.
The scanner is configured with useDefaultFilters=false, isCandidateComponent overridden to return true, and a pass-through include filter. That combination surfaces every class under the package — sealed interfaces, abstract classes, records, enums, and anything introduced in a subpackage in the future — without any per-release curation. New mocapi-model types are picked up automatically.
What Spring AOT handles (no explicit hints needed)¶
- Every auto-config class and
@ConfigurationPropertiesrecord — Spring Boot's AOT generates the binding code. - Every framework Spring bean (
DefaultMcpServer,McpToolsService,StreamableHttpController, etc.) — Spring AOT replaces reflective bean instantiation with generated factory code. - Spring-owned reflective annotation discovery on method-level annotations (
@McpTool,@McpPrompt,@McpResource,@McpResourceTemplate) — handled via merged-annotation pre-computation at AOT time.
Tests¶
mocapi-autoconfigure/src/test/java/.../aot/MocapiRuntimeHintsTest.java asserts coverage on representative types:
McpExchangeRequestStatePayload— the MRTRrequestStatepayload; it lives outsidemocapi-model, so it needs an explicit assertion (the package scan would not catch its regression)- Envelope results (
CallToolResult,GetPromptResult,ReadResourceResult,ListToolsResult) - Descriptors (
Tool,Prompt,Resource,ServerCapabilities) - Sealed hierarchies —
ContentBlock+TextContent,ResourceContents+TextResourceContents+BlobResourceContents - Nested (
PromptMessage)
mocapi-autoconfigure/src/test/java/.../aot/MocapiServicesAotProcessorTest.java covers the per-bean processor.
mocapi-apps/src/test/java/.../aot/AppsResourceAotProcessorTest.java covers the resource-inclusion processor: a classpath-scheme @McpUi(resource = ...) bundle gets a registered pattern, a ${...}-placeholder-valued one resolves through the embedded-value resolver first, a file:-scheme location is skipped, and a bean with no resource() attribute contributes nothing.
When new model types land, these tests currently pass automatically because of the package scan — but it's worth adding an assertion for anything with a non-trivial shape (new sealed hierarchies especially) to catch regressions if the scan filters ever change.
Extension modules own their hints¶
MocapiRuntimeHints only scans com.callibrity.mocapi.model — core has no
reason to know about extension-owned packages, and widening the scan to reach
into mocapi-tasks or mocapi-apps would leak extension knowledge into core
(a layering violation the same way a new SPI or transport dependency would
be). Each extension that introduces its own wire types crossing the Jackson
codec boundary is responsible for registering its own hints, following the
same RuntimeHintsRegistrar + META-INF/spring/aot.factories contribution
pattern MocapiRuntimeHints uses.
This gap was found empirically: a native-image build of examples/tasks
returned HTTP 500 on any tools/call that dispatched as a task, with
com.oracle.svm.core.jdk.UnsupportedFeatureError: Record components not
available for record class com.callibrity.mocapi.tasks.model.CreateTaskResult
in the server log. mocapi-tasks' wire records lived entirely outside
MocapiRuntimeHints' scan, so Jackson's record introspection had no
reflection metadata for them at runtime.
TasksRuntimeHints (mocapi-tasks)¶
com.callibrity.mocapi.tasks.aot.TasksRuntimeHints scans
com.callibrity.mocapi.tasks.model — a dedicated model package, mirroring
core's com.callibrity.mocapi.model — using the same
ClassPathScanningCandidateComponentProvider configuration as
MocapiRuntimeHints (useDefaultFilters=false, isCandidateComponent
overridden to true, pass-through include filter). The scanner is copied
locally rather than shared from core, keeping core extension-agnostic.
Covers CreateTaskResult, GetTaskResult, UpdateTaskResult,
CancelTaskResult, their *Params counterparts, and the TaskStatus enum.
AppsRuntimeHints (mocapi-apps)¶
com.callibrity.mocapi.apps.aot.AppsRuntimeHints takes a different shape
because mocapi-apps has no dedicated .model subpackage — its
com.callibrity.mocapi.apps package mixes wire records with annotations,
customizers, and services that never cross the Jackson codec boundary.
Rather than widen a package-wide scan to cover a handful of types, it
explicitly registers the three records AppsToolUiMetaCustomizer and
AppsResourceUiMetaCustomizer hand to ObjectMapper#valueToTree:
McpUiToolMeta, UiResourceMeta, and its nested McpUiResourceCsp — the
same explicit-registration style MocapiRuntimeHints uses for McpExchange
and RequestStatePayload.
AppsResourceAotProcessor (mocapi-apps) — resource inclusion, a separate hint category¶
AppsRuntimeHints only covers Jackson reflection for the _meta.ui wire
records. It does not cover a second, unrelated GraalVM concern: resource
inclusion. AppUiResourceContributor (mocapi-autoconfigure) reads an
@McpUi(resource = ...) bundle's raw bytes off the classpath via
ResourceLoader#getResource(String) at bean-construction time (ADR-0036).
GraalVM does not bundle arbitrary classpath resources into the native binary
unless a resource-inclusion hint (RuntimeHints.resources().registerPattern(...))
names them — a completely separate mechanism from the reflection hints above.
This gap was found empirically, one build after the mocapi-tasks reflection
gap above: a native-image build of examples/apps crashed on ApplicationContext
refresh, not with the UnsupportedFeatureError the tasks gap produced, but with:
Caused by: java.io.FileNotFoundException: class path resource [ui/get-time/mcp-app.html]
cannot be opened because it does not exist
— even though the resource was genuinely present in the jar. AppsRuntimeHints
registered no resource pattern at all; being added "by symmetry" with the tasks
fix, it inherited that fix's frame (Jackson reflection) but not this module's
actual sharp edge (reading raw bundle bytes).
com.callibrity.mocapi.apps.aot.AppsResourceAotProcessor closes it: a
BeanRegistrationAotProcessor (same shape as MocapiServicesAotProcessor,
registered per-bean rather than as a RuntimeHintsRegistrar) that, for every
bean whose class carries an @McpUi-annotated method with a non-blank
resource(), resolves ${...} placeholders via the owning bean factory's
embedded-value resolver — the same mechanism AppUiResourceContributor uses
at runtime — and registers a hints.resources().registerPattern(...) for the
resolved classpath location. file: (and other non-classpath-scheme)
locations are skipped: they are read from outside the image at runtime and
need no inclusion hint.
Placeholder limitation: if a ${...}-valued resource() attribute cannot
be resolved at AOT-processing time (e.g. the property isn't bound yet in that
phase), the processor falls back to registering the literal, unresolved
string as the pattern and logs a warning. If the actually-resolved runtime
value differs from the literal, that fallback pattern won't match, and the
bundle needs a manual RuntimeHintsRegistrar entry for the real location. In
practice this only bites placeholders resolved from a source not yet active
during AOT processing (profile-specific property files, etc.) — a build-time
application.properties value resolves fine, as covered by
AppsResourceAotProcessorTest.
Verification¶
The cowork-connector-example at ~/IdeaProjects/cowork-connector-example is the reference consumer. After publishing a mocapi candidate:
- Bump
mocapi.versionin its pom. mvn -Pnative spring-boot:build-image -DBP_NATIVE_IMAGE=true.- Run the resulting image and exercise
server/discover,tools/list, eachtools/call,prompts/list, eachprompts/get, and — critically — a full elicitation round-trip (a tool that callsctx.elicit(...), then the client retry carryingrequestState+ answers). Elicitation replay is the only path that exercises the MRTRrequestStatecodec, and thus the only way to catch a missingRequestStatePayloadhint. A discover/tools/prompts-only smoke check would have shipped that gap.
If any call errors with MissingReflectionRegistrationError or a Jackson InvalidDefinitionException, the offending class tells you whether it's mocapi's responsibility (extend MocapiRuntimeHints or MocapiServicesAotProcessor) or a consumer's (file bug in the appropriate repo).