ADR-0036 — @McpUi(resource=…) serve-mode for UI bundles¶
- Status: Accepted
- Date: 2026-08-01
Context¶
MCP Apps (ADR-0033) links a
tool to a ui:// HTML bundle: the tool carries @McpUi("ui://…"), and
the bundle is served by a resource declared with the same URI. Today that
resource must be a hand-written @McpAppResource method that loads the
bundle and returns it — the exact boilerplate ADR-0035 set out to remove:
@McpAppResource(uri = "ui://get-time/mcp-app.html")
public ReadResourceResult bundle() throws IOException {
var html = new ClassPathResource("ui/get-time/mcp-app.html")
.getContentAsString(UTF_8);
return ReadResourceResult.ofText("ui://get-time/mcp-app.html",
"text/html;profile=mcp-app", html);
}
Two declarations (the @McpUi link and the @McpAppResource method) must
agree on the URI by hand, and every app author re-writes the same loader.
ADR-0035 built exactly the
seam needed to collapse this — reader-backed handlers plus a
ResourceContributor — and explicitly deferred this serve-mode as "a
separate decision built on this."
The hard constraint carried over from ADR-0035 is that the ui:// URI is
logical and author-controlled; the served location is a fixed,
author-specified classpath/filesystem path, never derived from client
input. A client-supplied path resolving to a file is path-traversal / local
file inclusion and stays rejected.
Decision¶
Add an optional resource attribute to @McpUi. When set, mocapi serves
that bundle for the annotation's ui:// URI; the author writes no resource
method.
@McpTool(name = "get-time")
@McpUi(value = "ui://get-time/mcp-app.html",
resource = "classpath:/ui/get-time/mcp-app.html")
public TimeResult getTime() { … }
Concrete rules:
- Two modes, by presence of
resource. resourceblank (unchanged): the author declares the bundle elsewhere (@McpAppResource/@McpResource) with the same URI;McpUiReferenceValidatorfails fast at boot if none matches.resourceset: an AppsResourceContributorcontributes a reader-only, publicReadResourceHandlerat thevalue()URI that serves the bundle, resolved once via SpringResourceLoaderat startup. A missing/unreadable location fails the boot with a clear error.- Logical URI, fixed location.
value()is the wire identity; the served bytes come only from the literalresourcelocation. Neither is ever built from request input. Noui://{path}catch-all. - MIME +
_meta.ui. The contributed resource istext/html;profile=mcp-appand carries a default_meta.ui(default CSP, no extra sandbox) — parity with a default@McpAppResource. - Reuse by many tools is fine; conflicts fail fast. Several tools may
@McpUithe samevalue(); the contributor registers one handler per URI. Two tools naming the samevalue()with differentresourcelocations is a configuration error and fails the boot. A serve-mode URI that also collides with a hand-declared resource URI fails fast via the service's existing duplicate-URI check. - Lightweight, public path — escape hatch unchanged. Serve-mode
resources carry no guards and no observability (ADR-0035's model:
contributed readers are the bare, public path). An author who needs
guards, o11y, custom CSP/sandbox, or generated content declares an
@McpAppResourcemethod instead and leavesresourceblank.
Layering: @McpUi.resource lives in mocapi-apps; the Apps
ResourceContributor (reusing HandlerMethodsCache and ResourceLoader)
lives in the apps autoconfiguration and contributes through the generic
ADR-0035 seam. No Apps concept crosses the core resources line.
Consequences¶
App authors get a working UI bundle from two lines on the tool — one
annotation, no loader method, no duplicated URI. The @McpAppResource
method remains the full-control form and the sole path for guarded, observed,
or dynamically-generated UI resources, keeping serve-mode deliberately thin.
Because the contributor resolves the bundle at startup, a typo'd path or a
missing file is a boot failure, not a blank iframe at render time — the same
fail-fast posture McpUiReferenceValidator already gives dangling links.
Costs: one public attribute on @McpUi, one new contributor, and a small
amount of conflict-detection logic. The @McpUi docs and the Apps design
doc change with the code.
Non-goals: custom CSP/sandbox on serve-mode (use @McpAppResource);
templated UI URIs; serving from a client-influenced location (rejected as
LFI); runtime registration (ADR-0035 rules it out).
Code anchors:
mocapi-apps/.../McpUi.java(resource()attribute)mocapi-autoconfigure/.../apps/AppUiResourceContributor.java(scans@McpUi(resource=…)tools, contributes reader-only handlers) + its registration inMocapiAppsAutoConfigurationmocapi-server/.../resources/ResourceResults.java(bundle wrapping) andResourceContributor(the ADR-0035 seam it plugs into)