MCP Apps¶
How mocapi implements the MCP Apps extension (io.modelcontextprotocol/ui,
SEP-1865, modelcontextprotocol/ext-apps) — the mocapi-apps module, the
_meta.ui shapes it writes, and the explicit boundary where mocapi stops.
For decisions, see:
- ADR-0033 — the
mocapi-appsmodule and theio.modelcontextprotocol/uicapability - ADR-0034 —
_metaonTool/Resourceand the original descriptor-customizer seams Apps was built on (amended by ADR-0039 — see below) - ADR-0039 —
folds the descriptor-customizer seam into the four
*HandlerCustomizerSPIs; Apps' two customizers are renamed accordingly - ADR-0032 —
meta-annotation-aware handler discovery, which lets
@McpAppResourceregister as a resource with no bespoke SPI - ADR-0031 —
ServerCapabilitiesCustomizer, whichUiCapabilityCustomizeruses to declare theuiextension capability - ADR-0035 —
function-backed resource readers and the
ResourceContributorseam that serve-mode plugs into - ADR-0036 —
@McpUi(resource=…)serve-mode: serving aui://bundle from a fixed location with no resource method
Design history: the original design spec is
docs/superpowers/specs/2026-07-31-mcp-apps-extension-design.md.
That spec proposed splitting the module into mocapi-apps-api +
mocapi-apps; the implementation collapsed that into a single
mocapi-apps module (annotations, _meta.ui records, customizers, and
MocapiAppsAutoConfiguration all together) — this doc describes what
shipped, not the original proposal.
Scope boundary¶
MCP Apps splits into two halves. mocapi implements only the first:
- Server surface (in scope): declare
ui://HTML resources, stamp_meta.uion tool and resource descriptors to link them, declare theio.modelcontextprotocol/uicapability. All of this is static, descriptor-time metadata — no new JSON-RPC methods, no runtime state. - Host / in-iframe JS surface (out of scope): the sandbox handshake
(
ui/initialize), thepostMessageJSON-RPC bridge,ui/notifications/sandbox-proxy-ready/sandbox-resource-ready, display-mode negotiation, app-registered tools, andsampling/createMessageoverpostMessage. mocapi serves the author's HTML bytes content-agnostically; when the in-iframe app calls a server tool, it arrives at mocapi as an ordinarytools/call— no Apps-specific server code is involved. mocapi ships nopostMessage/ iframe / JS-bridge code of any kind, and never will under this design — see ADR-0033's non-goals.
This mirrors the split in docs/adr/0022-2026-07-28-features-not-implemented.md:
ADR-0033 flips that entry from declined to accepted-and-implemented,
specifically for the server half.
Module layout¶
mocapi-apps depends on mocapi-api (for the @McpResource/@McpTool
meta-annotation targets) and mocapi-server (for the descriptor and
capability customizer seams):
mocapi-apps
McpAppResource — annotation: declares a ui:// resource
McpUi — annotation: links a tool to its ui:// resource
Csp — annotation: CSP origins for a ui:// resource
UiResourceMeta — record: resource _meta.ui shape (csp, sandbox)
McpUiResourceCsp — record: connect/resource/frame/baseUri domain lists
McpUiToolMeta — record: tool _meta.ui shape (resourceUri, visibility)
AppsResourceUiMetaCustomizer — reads @McpAppResource → writes Resource._meta.ui (ReadResourceHandlerCustomizer)
AppsToolUiMetaCustomizer — reads @McpUi → writes Tool._meta.ui (CallToolHandlerCustomizer)
UiCapabilityCustomizer — declares capabilities.extensions["io.modelcontextprotocol/ui"]
MocapiAppsAutoConfiguration (in mocapi-autoconfigure, gated
@ConditionalOnClass(UiCapabilityCustomizer.class)) registers the three
customizers as @ConditionalOnMissingBean beans whenever mocapi-apps
is on the classpath, plus the serve-mode AppUiResourceContributor (also
in mocapi-autoconfigure — it needs HandlerMethodsCache and
ResourceLoader, both Spring-side). Omitting the module leaves the core
inert: no descriptor gains _meta, and handler discovery behaves
identically for plain @McpResource/@McpTool methods.
_meta.ui shapes¶
Apps is the first consumer of the generic descriptor _meta seam
(ADR-0034). Tool and Resource carry an optional _meta
(ObjectNode, NON_NULL — omitted from the wire when no customizer
touches it); Apps writes a ui key into it.
Resource _meta.ui — UiResourceMeta¶
Written by AppsResourceUiMetaCustomizer when a @McpResource
method (or, via meta-annotation, @McpAppResource) carries the merged
@McpAppResource annotation:
{ "ui": { "csp": { "connectDomains": ["https://api.weather.com"] },
"sandbox": ["allow-scripts"] } }
csp(McpUiResourceCsp):connectDomains,resourceDomains,frameDomains,baseUriDomains— the CSP origins the UI needs. The server declares what's needed; the host enforces it on the iframe.null(customizer omits the wholecspobject) when every list on@Cspis empty.sandbox: requested iframe sandbox permissions (raw strings, no enum — the spec doesn't fix a closed set).nullwhen@McpAppResourcedeclares nosandboxvalues.
v1 targets the listing/static form only — a per-response override
on the resources/read content item exists in the draft spec but is
explicitly deferred (§6.3 of the design spec); mocapi has no call-time
UiContext to produce one.
Tool _meta.ui — McpUiToolMeta¶
Written by AppsToolUiMetaCustomizer when a @McpTool method
carries @McpUi:
{ "ui": { "resourceUri": "ui://weather/dashboard",
"visibility": ["model", "app"] } }
resourceUri: the linkedui://resource.visibility:["model", "app"]by default. This is the MCP Apps UI access axis (should the model see this tool, should the app UI see it, or both) — a different concept from the auth-Guardvisibility ≡ invocationmodel inauthorization-model.md/ ADR-0012. mocapi emits it as metadata only; it is not enforced server-side. A host is responsible for acting on it.
Capability declaration¶
UiCapabilityCustomizer implements ServerCapabilitiesCustomizer
(ADR-0031) and
unconditionally adds:
{ "capabilities": { "extensions": {
"io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] } } } }
mocapi is stateless (server/discover, no initialize handshake), so
there's nothing to gate registration on — the capability and _meta.ui
are always emitted. A non-Apps host sees an unrecognized extensions
entry and unrecognized _meta.ui fields and ignores both, per the
spec's text-only fallback.
Author-facing API¶
@McpAppResource — declare a ui:// resource¶
@McpAppResource is a meta-annotation over @McpResource
(mimeType defaulted to text/html;profile=mcp-app; uri/name
aliased through via @AliasFor). Meta-annotation-aware discovery
(ADR-0032) means the method registers exactly like a hand-written
@McpResource — there is no separate Apps registration path:
@McpAppResource(
uri = "ui://weather/dashboard",
name = "Weather",
csp = @Csp(connect = "https://api.weather.com"))
public ReadResourceResult dashboard() {
return ReadResourceResult.ofText(
"ui://weather/dashboard", "text/html;profile=mcp-app", html);
}
Like every @McpResource handler, the method returns a
ReadResourceResult or one of the convenience return types
(String/CharSequence, byte[]/ByteBuffer, Spring Resource;
ADR-0035) — ReadResourceHandlers.validateReturnType accepts any of
these at startup regardless of which annotation registered the method.
AppsResourceUiMetaCustomizer runs after the Resource descriptor
is otherwise built and reads csp()/sandbox() off the merged
annotation (via AnnotatedElementUtils.findMergedAnnotation) to write
_meta.ui.
@McpUi — link a tool to its UI resource¶
A companion annotation on an existing @McpTool method — it changes no
discovery behavior, only descriptor metadata:
@McpTool(name = "get_weather", description = "Get weather")
@McpUi("ui://weather/dashboard")
public WeatherResult getWeather(Args a) { … }
AppsToolUiMetaCustomizer reads @McpUi and writes
_meta.ui.resourceUri (+ visibility). The per-call data the UI
renders rides the normal CallToolResult (typically
structuredContent) — no special context object is involved.
By default the linked value() URI must be declared elsewhere on the
server; McpUiReferenceValidator (a SmartInitializingSingleton) fails
the boot if a @McpUi points at a URI no handler declares — turning a
fat-fingered link into a clear startup error instead of a blank iframe.
@McpUi(resource=…) — serve-mode (ADR-0036)¶
Setting @McpUi.resource to a fixed location makes the resource method
optional: AppUiResourceContributor scans every @McpUi(resource=…)
tool and contributes, through the generic ADR-0035 ResourceContributor
seam, a public, reader-only text/html;profile=mcp-app resource at
value() serving the bundle resolved once at startup via Spring
ResourceLoader:
@McpTool(name = "get_weather", description = "Get weather")
@McpUi(value = "ui://weather/dashboard",
resource = "classpath:/ui/weather-dashboard.html")
public WeatherResult getWeather(Args a) { … }
The URI is logical and author-controlled; the served bytes come only
from the literal resource string — never request input, so there is no
path-traversal / LFI surface (a ui://{path} catch-all was rejected for
exactly this reason). Several tools may reuse one value(); the
contributor registers one resource per URI, and the same value() with
two different locations — or a missing bundle — fails the boot. Serve-mode
resources carry no guards, no observability, and a default _meta.ui
(default CSP, no extra sandbox); anything needing policy, custom
@Csp/sandbox, or generated content uses an @McpAppResource method and
leaves resource blank. Because the contributed URI is now declared,
McpUiReferenceValidator is satisfied without a hand-written resource.
What was deliberately not added¶
No call-time UiContext injectable. The tool↔UI link is static
descriptor metadata surfaced in tools/list before any invocation,
which a call-time context structurally cannot supply. The only dynamic
case in the spec — a per-response _meta.ui override on
resources/read content — is draft-only and out of scope for v1; see
ADR-0033's "Consequences" section for the forward-compatibility note.
Descriptor-customizer seam recap¶
Apps was the first consumer of ADR-0034's generic descriptor-_meta
seam. ADR-0039 folded that seam's originally-standalone descriptor
customizer interfaces into the existing per-handler-kind customizers
(see ADR-0034's
amendment note for the prior shape), so AppsToolUiMetaCustomizer and
AppsResourceUiMetaCustomizer are ordinary CallToolHandlerCustomizer
/ ReadResourceHandlerCustomizer beans that happen to call the
descriptor(T) mutator:
public class AppsToolUiMetaCustomizer implements CallToolHandlerCustomizer {
@Override
public void customize(CallToolHandlerConfig config) {
// ... build McpUiToolMeta from @McpUi ...
Tool descriptor = config.descriptor();
config.descriptor(descriptor.withMeta(meta));
}
}
CallToolHandlers.build and ReadResourceHandlers.build run every
registered *HandlerCustomizer over the generated descriptor at the same
build-pipeline point the old descriptor-only customizers ran; each
customizer reads config.method() for its annotations and calls
config.descriptor(T) to fold in _meta. Core has no knowledge of what
"ui" means — it only offers "run every registered customizer over this
handler before it's published." See Extension SPI,
Handlers, and the Extending mocapi
guide
for the customizer pattern in general.
Flows¶
- Discovery. Host calls
server/discover, sees theio.modelcontextprotocol/uicapability.resources/listincludesui://resources with their_meta.ui(CSP/sandbox).tools/listincludes tools carrying_meta.ui.resourceUri. - Render. Model calls a tool → mocapi returns a normal
CallToolResult. Because the tool descriptor carried_meta.ui.resourceUri, the host fetches thatui://resource viaresources/readand renders it in a sandboxed iframe per the resource's declared CSP/sandbox. Everything from here on is host/iframe — mocapi's involvement ends at serving the HTML bytes. - App calls a server tool. An action inside the rendered app
(e.g. "Refresh") goes App → Host → Server, arriving at mocapi as
an ordinary
tools/call. No Apps-specific server code runs.
Testing¶
AppsUiMetaCustomizerTest— unit coverage ofAppsToolUiMetaCustomizer/AppsResourceUiMetaCustomizerproducing correct_meta.ui.AnnotationContractTest— the@AliasFor/meta-annotation contract on@McpAppResource(uri/mimeType merge correctly).UiMetaSerializationTest— wire-shape serialization ofUiResourceMeta/McpUiToolMeta/McpUiResourceCsp.AppsEndToEndTest(inmocapi-autoconfigure) — a real Spring context wiringMocapiServerToolsAutoConfiguration,MocapiServerResourcesAutoConfiguration,MocapiServerAutoConfiguration, andMocapiAppsAutoConfiguration, assertingtools/list,resources/list, andserver/discoverall carry the expected Apps metadata.AppUiServeModeTest(inmocapi-autoconfigure) — serve-mode: a@McpUi(resource=classpath:…)tool boots with theui://resource contributed and served from the classpath, and the same URI from two locations fails the boot.
There is no Apps-specific conformance-suite coverage: the
ext-apps Playwright/e2e suite tests the host/iframe handshake, which
is out of scope for mocapi's server.