ADR-0035 — Function-backed resource readers and a ResourceContributor seam¶
- Status: Accepted
- Date: 2026-08-01
Context¶
Resource registration is entirely annotation-scan-based
(ADR-0010): every
@McpResource / @McpResourceTemplate method becomes a
ReadResourceHandler / ReadResourceTemplateHandler, and those are
final classes whose read() is a reflective MethodInvoker.invoke
over the annotated method. McpResourcesService is built once from that
fixed list. There is no way to register a resource that is not a
scanned method — which blocks serving a file with minimal boilerplate,
letting an optional module contribute resources, or generating a
resource programmatically.
The motivating driver is MCP Apps: an author wants to serve a ui://
HTML bundle from a file and have a tool's @McpUi link to it, ideally
without hand-writing a ReadResourceResult-returning method. Working
that through surfaced hard constraints:
- A catch-all
ui://{path}template is rejected — it turns the client-supplied request URI into a filesystem locator (path traversal / local-file inclusion). The template variable is untrusted input, not a resource identifier. - Core must stay ignorant of the extension.
mocapi-serverand the core resources autoconfiguration must know nothing about@McpUi,ui://, or "Apps". - No runtime registration. All resources are known at startup; post-startup mutation (and its concurrency machinery) is unwanted.
- Authors want file/text/binary returns without building a
ReadResourceResultby hand.
Decision¶
Invert the resource handler around a reader function, and make registration flow through a single generic contributor seam.
Function-backed handlers. Introduce strongly-typed functional
interfaces in mocapi-server:
@FunctionalInterface public interface ResourceReader { ReadResourceResult read(); }
@FunctionalInterface public interface ResourceTemplateReader { ReadResourceResult read(Map<String,String> vars); }
ReadResourceHandler / ReadResourceTemplateHandler carry
(descriptor, guards, reader). The reflective form is one reader
adapter — () -> (ReadResourceResult) invoker.invoke(...) — that keeps
the method's MethodInvoker (and thus its interceptor/guard chain).
New reader kinds are just other implementations; no reflection required.
One registration mechanism — the ResourceContributor seam.
public interface ResourceContributor {
default List<ReadResourceHandler> resources() { return List.of(); }
default List<ReadResourceTemplateHandler> resourceTemplates() { return List.of(); }
}
McpResourcesService is built once, at construction, by merging the
handlers from every ResourceContributor bean. The annotation scan
is itself the primary, built-in contributor — not a privileged path;
it is one contributor among peers. The service stays immutable;
there is no runtime registration (see Consequences).
Author-friendly return types, implemented as readers. A resource
method may return ReadResourceResult / String / CharSequence /
byte[] / ByteBuffer / Resource:
String/CharSequence→ text;byte[]/ByteBuffer→ blob (ByteBufferread viaduplicate(), non-destructive).Resource→ text or blob via acontentenum on@McpResource—AUTO(default) uses the declaredmimeType(base typetext/*, or subtypejson/xml/javascript/ecmascriptincl.+json/+xml→ text; else blob; blank/unknown → blob), elseTEXT/BLOBforced. Text charset is UTF-8 (honoring a mimecharsetparam if present); a malformed mime degrades to blob rather than failing the read.ReadResourceResultremains the full-control escape hatch.
Update (1.1.0): templates are symmetric. A @McpResourceTemplate
method may return the same convenience types (and gains the same content
attribute), not just ReadResourceResult. The wrapper needs the resource's
concrete URI to stamp into the contents; for a template that is the
matched request URI, so ResourceTemplateReader takes it —
read(String uri, Map<String,String> vars) — and McpResourcesService
threads the request URI in when it dispatches a matched template read. (The
original decision scoped convenience returns to fixed @McpResource to
avoid this URI threading; the asymmetry was surprising, and the threading
is clean, so it was lifted before 1.1.0 shipped.)
Layering — core is extension-blind. mocapi-server defines the
readers, the ResourceContributor SPI, and the return-type conversion;
MocapiServerResourcesAutoConfiguration collects
List<ResourceContributor> generically. An extension (e.g. MCP Apps'
@McpUi(resource=…) serve mode — a separate decision built on this)
contributes from its own layer, reusing the existing scan to find its
declarations. No Apps/UI concept crosses the core line.
Guards and o11y stay with the method form. guards remains a plain
field on the handler, general to all readers, and the service keeps
filtering listResources by it (empty guards → visible). But
enforcement and o11y are not generalized: method-backed readers keep
their baked-in MethodInvoker strata (correlation / observation / audit
+ guard enforcement); contributed readers carry an empty guards list
(public) and a bare reader with no interceptors. Nothing is lifted into
the service, and no AnnotatedElement guard-source abstraction is
introduced — both would solve problems we do not have (guarded/observed
non-method resources). The escape hatch is deliberate and already
exists: a resource that needs guards, o11y, or logic is declared as a
method (@McpResource / @McpAppResource) and reached by reference,
which routes it through the full chain. Serve-mode contributed resources
are the lightweight, public path by design.
Consequences¶
Registering any new resource — a file, a module-contributed one, a generated one — becomes "hand the service a reader through a contributor." The annotation scanner is demystified into one contributor among peers, which is a cleaner mental model. Authors get file/text/ binary returns with near-zero boilerplate, and the return-type mapping is general (not Apps-specific). The core remains ignorant of every extension; the Apps boundary sits entirely above the core line. Because everything is construction-time, the service stays immutable with no concurrency surface.
Costs: ReadResourceHandler / ReadResourceTemplateHandler change from
method-bound to reader-backed (touching the build path and the o11y/guard
integration of the method form); a new ResourceContributor SPI and its
collection; a return-type converter. Bounded, but a real change to a
constitution-guarded subsystem.
Rejected / deferred: the catch-all ui://{path} template (LFI risk);
runtime register(...) with copy-on-write registries (no current use
case — the design keeps the maps private so it could be added later
without API churn, but it is not built). Non-goal: per-request dynamic
content beyond what a reader already gives (a reader is invoked per read,
so dynamic content is inherent to the model).
Code anchors:
mocapi-server/src/main/java/com/callibrity/mocapi/server/resources/ResourceReader.java,ResourceTemplateReader.java,ResourceContributor.javamocapi-server/.../resources/ReadResourceHandler.java,ReadResourceTemplateHandler.java(reader-backed),ReadResourceHandlers.java/ReadResourceTemplateHandlers.java(method → reader adapter + return-type conversion)mocapi-server/.../resources/McpResourcesService.java(construction-time merge of contributors)mocapi-autoconfigure/.../MocapiServerResourcesAutoConfiguration.java(scan-as-contributor +List<ResourceContributor>collection)mocapi-api/.../resources/McpResource.java(contentenum) + the enum type