MCP Apps (SEP-1865) lets an MCP server hand the host a small web page to render beside a tool result. The host puts that page in a sandboxed iframe under a strict Content Security Policy and talks to it with JSON-RPC over postMessage.
@beatzball/litro-agent/mcp-app turns a Litro component into that page: one self-contained HTML5 document, server-rendered, with no external URL and nothing to download.
That last part is the difference. Every other MCP Apps implementation ships a client-rendered bundle, so the iframe starts empty and paints once the framework arrives. A Litro-packed document is real, styled markup in the first byte the host receives.
A ui:// resource is a static, cached, data-free template.
The specification separates presentation from data on purpose, and hosts may prefetch a template and treat it as immutable across many tool calls. Data reaches the view afterwards, by notification:
| Step | Direction | Message |
|---|---|---|
| 1 | view → host | ui/initialize |
| 2 | host → view | hostContext — theme, locale, dimensions. Not the tool result. |
| 3 | host → view | ui/notifications/tool-input — the arguments |
| 4 | host → view | ui/notifications/tool-result — structuredContent |
So the ui() helper's per-call rendered html does not map onto a ui:// resource, and the two must not be wired together. What you package is a data-free shell, rendered once. The bridge inside the document fills it at step 4.
Apps live in mcp-apps/, one file each, default-exported:
// mcp-apps/weather-card.ts
import { defineMcpApp } from '@beatzball/litro-agent/mcp-app';
import { html } from 'lit';
import { DemoWeatherCard } from '../components/demo-weather-card.js';
void DemoWeatherCard; // named import + void: bare side-effect imports get tree-shaken
export default defineMcpApp({
title: 'Weather',
// Rendered with no data — a host caches this and reuses it every call.
shell: html`<demo-weather-card city="—" summary="Waiting…"></demo-weather-card>`,
styles: 'body { margin: 0; padding: 8px; background: transparent; }',
prefersBorder: true,
});
shell is a template for whichever renderer LITRO_ADAPTER selects — a Lit TemplateResult, or an HTML string for FAST. Elena is not supported, because ui() does not support it yet.
There is no uri in that file. The packer derives one: the package name is the authority, and the file path is the path.
For a project whose package.json says "name": "playground":
File in mcp-apps/ | Address |
|---|---|
weather-card.ts | ui://playground/weather-card |
weather/card.ts | ui://playground/weather/card |
dashboard/charts/bar.ts | ui://playground/dashboard/charts/bar |
One rule, no special cases: rename the package and every address moves together, which is what an authority is for.
The authority comes from the package rather than the first folder because a ui:// address needs a host and a path. If the first folder were the authority, a file sitting flat in mcp-apps/ would give host weather-card and an empty path — a different shape from every nested file, which a host that groups by authority treats differently. A scoped name contributes only its last part: @beatzball/playground gives playground. The name must be usable as a uri host — lowercase letters, digits, ., _ and -, starting with a letter or digit — so a package called @acme/MyApp cannot supply one. Such a project still packs, but every app in it has to set uri itself.
index.ts is not special. In pages/, blog/index.ts serves the folder root. Here weather/index.ts is ui://playground/weather/index — collapsing it would silently merge with a sibling weather.ts.
No dynamic segments in a derived address. [slug], [[opt]] and [...all] mean something in pages/ and nothing here. A ui:// resource is a static template the host caches by address, so there is no request to fill a parameter from. The build rejects them rather than shipping a literal [slug] in a protocol-visible address — unless the app sets its own uri, in which case the filename is just a filename.
uri is still accepted, and an explicit one always wins over the derived value:
export default defineMcpApp({
uri: 'ui://weather/current-conditions',
shell,
});
Use it when the address must not follow the filename, or when you call buildMcpAppDocument() yourself — with no file there is nothing to derive from, and it throws unless the config carries a uri or you pass one:
await buildMcpAppDocument(app, { uri: 'ui://weather/card' });
Do not read the address off the config. app[MCP_APP_CONFIG].uri is typed string | undefined, and for an app that relies on derivation it is undefined at runtime — the config never learns the derived value, because only the packer knows the file path and the package name. Code that assigned it to a string before uri became optional now fails to type-check, and adding ! only hides the undefined.
Read the resolved address from what the build produces instead. Each of these is a string, and each is the same value the host will cache the template under:
descriptor.uri on the result of buildMcpAppDocument();uri in each app's .json descriptor under dist/mcp-apps/;uri on each entry of dist/mcp-apps/manifest.json.const { descriptor } = await buildMcpAppDocument(app, { uri: fallback });
descriptor.uri; // string — the config's uri if it has one, else the fallback
litro mcp-app build
Every .ts, .tsx and .mts file under mcp-apps/, at any depth, is packed into dist/mcp-apps/ — except four kinds of name that are skipped without a word: a declaration file (*.d.ts, *.d.mts), a test (*.test.*), a spec (*.spec.*), and any file whose name starts with -. The leading dash is the same switch pages/ uses — rename card.tsx to -card.tsx to turn it off without deleting it. It matches a filename, not a folder, so -drafts/card.ts still builds. A skipped file produces no output and no warning, so check the name first if an app seems to vanish. The output mirrors the source tree, so the file path and the output path are the same path — and so is the address, whenever it is derived:
| Source | Output | Address |
|---|---|---|
mcp-apps/weather-card.ts | dist/mcp-apps/weather-card.html + .json | ui://playground/weather-card |
mcp-apps/weather/card.ts | dist/mcp-apps/weather/card.html + .json | ui://playground/weather/card |
manifest.json lists every app, so a server can load them in one read. Its html and descriptor entries are paths relative to the output directory, so a nested app is weather/card.html.
--dir and --out override the defaults.
Most problems are reported all at once, before a module is loaded or a byte is written. The second group below is the exception: whether those apply depends on what the app declares, so they can only surface once the file is loaded — by which time earlier apps are already on disk.
Refused no matter what the app says:
Two files, one output. weather/card.ts and weather/card.tsx both pack to weather/card.html. (weather/card.ts and weather-card.ts do not clash — mirroring the tree is what makes that impossible.)
A # or ? in the name. The module loader reads an id as a url, so weather#card.ts is looked up as weather and reported as missing for a file that plainly exists.
A C0 control character or line separator (U+2028, U+2029) in the name. These have no printable form, and the path is written verbatim into manifest.json, into the descriptor, and into every error message about the app. U+007F is not refused: the line is drawn at the C0 range, and DEL sits just outside it. It is invisible too, so this is a boundary rather than a principle — as are the zero-width characters above it.
A backslash in the name. The build resolves app files to absolute paths, and that rewrites a\b.ts into a/b.ts — so the loader would look for a file that is not there.
Setting uri does not help for any of those three: none of them is about the address.
A . or .. segment. Neither survives normalization, so the path written down is not the path that gets resolved — and enough leading .. lands outside the output directory entirely.
An app packing to manifest at the top level. manifest.json is the index, and it would overwrite the app's own descriptor. Put it in a folder, or rename it. Matched without regard to case, because Manifest.json is the same file on macOS and Windows.
Two apps claiming one address. Only reachable by writing uri by hand. A host caches templates by URI, so a collision does not merge or warn — one app would quietly serve the other's markup. Compared by RFC 3986 equivalence, so ui://PKG/a and ui://pkg/a are one address, not two.
Refused only when the address is being derived, and excused by setting uri yourself:
., _, ~ and -. A space, % or a non-ASCII letter is refused, because a parser rewrites it — big card.ts would ship ui://playground/big card while a host caches under ui://playground/big%20card, and the descriptor would name a resource the host cannot find. With an explicit uri the filename is only a filename, and big card.ts packs to big card.html.[slug], [[opt]], [...all]. Same rule: refused for a derived address, fine alongside an explicit one.The output is plain files. Any MCP server can serve them; nothing assumes the serving side is a Litro one.
structuredContent is server JSON, and tool results routinely carry
third-party text. The default fill step therefore refuses any key that is a
scripting sink — anything starting with on, plus innerHTML, outerHTML,
srcdoc, src, href, action, formaction, style and friends. Refused
keys are reported to the host with notifications/message, not dropped
silently.
Without that, a result of { "innerHTML": "<img src=x onerror=…>" } would
execute: the host's default CSP is script-src 'self' 'unsafe-inline', which
permits inline event handlers, and the injected code would hold
window.litroMcp.callTool.
A custom apply bypasses the deny list — it is your code, so it is your call.
So does runtime, less obviously. The bridge reads litroMcpApply off the global when a result arrives, so a runtime that assigns that name replaces the default fill step without ever declaring an apply. Every tool result then reaches your code with no check on it.
The build warns when it sees that assignment, so it stops being something you do by accident. It does not stop you — replacing the fill step is a reasonable thing to want.
That warning is a text match, not a parse, and it is worth knowing what it does and does not catch. It finds the name in assignment position however you reach it — window., globalThis., self., a local alias, bracket notation, ??= — and it strips comments first. Two gaps remain: a string that merely mentions the name still warns, and Object.assign(window, { litroMcpApply: fn }) does not. Closing either needs a real parse of your source, which is more than a warning is worth.
The deny list is narrow on purpose. Two things are outside it.
Your component's own setters. The default fill step sets every key it does not refuse: el[key] = data[key]. The deny list knows the built-in sinks. It cannot know the properties your component defines. So if a component in your runtime has a setter that writes HTML, a URL or a style, a tool result reaches that sink by using the property's name:
class RichCard extends HTMLElement {
set summary(value) { this.innerHTML = value; }
}
A result of { "summary": "<img src=x onerror=…>" } puts that image in the card. Nothing is refused and nothing is reported, because summary is an ordinary name.
Do one of two things:
textContent, not innerHTML.apply that copies only the fields you expect, as in Filling the shell.Network. The deny list is about scripting. It does not limit what the view can load or connect to. That is the CSP's job, and it depends on _meta.ui.csp:
csp: the spec has the host use default-src 'none' and connect-src 'none'. The view can reach no outside origin.connectDomains or resourceDomains declared: anything that runs in the view may reach those origins. That includes a component setter from the gap above.So declare only the origins the view really needs, and omit csp when it needs none.
runtime and apply are strings, and that has a costBoth are browser source as a string. Nothing type-checks them, nothing lints them, and your editor cannot help. A typo is invisible until the document is inside a host, where a SyntaxError kills the whole script tag and the view simply never fills — with no error you will see.
The build parses both and refuses source that does not compile. That is a real check and it is the only one there is:
defineMcpApp: "runtime" is not valid JavaScript — Unexpected token '{'
It is a parse, not an execution — no code of yours runs at build time.
Two things it cannot do, and you should not read it as doing:
runtime and apply are trusted author code by design: they run as the page, they hold window.litroMcp.callTool, and the host's default CSP permits inline script. Treat them like any script tag you would add to a page you own.Never build either string from data. Interpolating a value into runtime is a code-injection sink, and nothing in the build can detect it:
runtime: `var city = "${input}";` // don't
Send data through structuredContent instead, which is what the fill step is for.
No component runtime is inlined by default, so the rendered Declarative Shadow DOM is static markup. Assigning .city on the element sets a property nothing is watching. Write to the DOM instead:
apply: `function (el, data) {
var root = el.shadowRoot;
if (!root) return;
root.querySelector('.city').textContent = String(data.city);
root.querySelector('.temp').textContent = data.tempC + '\\u00B0C';
}`,
apply is browser source as a string, never a function. A function would have to be serialized with Function.prototype.toString(), which silently drops everything it closed over — and that breaks inside the iframe, where nobody is watching.
Omit it and the default is a guarded property assignment (see What the fill step refuses), which is right when you have inlined a runtime through runtime:. That costs the runtime's bytes in every copy of every app, because self-containment means nothing can be shared between documents.
The bridge exposes window.litroMcp.callTool(name, args), which the host forwards to the server as tools/call. A view is not limited to the one result it was rendered for:
runtime: `
document.addEventListener('click', function (event) {
if (!event.target.closest('#refresh')) return;
var status = document.getElementById('status');
status.textContent = 'Refreshing…';
window.litroMcp
.callTool('get-weather', { city: 'Doha' })
.then(function (result) {
status.textContent = '';
window.litroMcpApply(document.getElementById('card'), result.structuredContent);
})
.catch(function (err) {
// The host answered with an error, or did not answer in time.
status.textContent = 'Could not refresh: ' + err.message;
});
});
`,
window.litroMcp.readResource(uri) is available on the same channel.
Both calls reject if the host does not answer within 30 seconds. The error's name is TimeoutError and its message names the method, for example MCP App request "tools/call" timed out after 30000ms. A late answer after that is ignored. Always handle the rejection. Without a .catch, a view that shows "Refreshing…" keeps showing it after the call has failed.
For a tool that is slow on purpose, pass a timeout as the last argument. 0 waits forever:
window.litroMcp.callTool('build-report', { year: 2026 }, { timeoutMs: 120000 });
window.litroMcp.readResource('ui://reports/latest', { timeoutMs: 0 });
The ui/initialize handshake has no timeout. A host can be slow to answer it, and the server-rendered shell is on screen while it waits.
Packing fails if the document would load anything from outside itself — an external src, a <link href>, a CSS url() or @import. Inline the CSS and JS, and embed images as data: URIs.
This is an error rather than a warning because the host's default CSP is default-src 'none'. A blocked request inside that iframe produces no console you will see and no error the host reports. A missing stylesheet just renders as an unstyled card.
Fonts are the exception worth knowing: the default policy has no font-src, so default-src 'none' blocks a data: webfont too. Use the host's own fonts — it offers them through hostContext.styles.
An <a href="https://…"> is not flagged. A link is a navigation, not a subresource load, and the default policy does not block it.
_meta.ui.csp declares the origins the view may reach: connectDomains, resourceDomains, frameDomains, baseUriDomains. Omit it entirely when the view needs no network — the spec has the sandbox "apply restrictive defaults if no CSP metadata is provided", which is tighter than handing it an empty object to build a policy from.domain becomes _meta.ui.domain: a request for a dedicated sandbox origin. Use it when the view needs the same origin every time — an OAuth callback, a CORS policy, or an API key allowlist. Omit it otherwise; the host then uses its default sandbox origin, typically one per conversation. The host decides the format and the final origin — the spec says "the format and validation rules for this field are determined by each host", so read your host's documentation for the value to use. Litro checks only that it is a non-empty string, and writes it through unchanged.Elena is not supported, and sampling/createMessage and host-registered tools
are not implemented. What each one costs a view, and the workaround where there
is one, is on Known Gaps.
Serving these documents, and an agent's tools, to a host is litro mcp serve.
playground/mcp-apps/ carries two: a read-only card, and one with a button that calls a tool back. Build them with pnpm --filter playground mcp-app.