UI resources (MCP Apps)
An MCP App is a ui://<server>/<app>/index.html resource served with MIME text/html;profile=mcp-app — a Svelte page bundled by Vite into a single HTML file, embedded into the Go binary at build time, and registered via runtime/apps. The Dockyard bridge shell (web/bridge) handles the ui/ postMessage handshake so your App reads the tool's structured payload as a typed value and renders.
The five parts
- Declare the app in
dockyard.app.yaml. - Wire each tool to the app with
ui: <id>(manifest) and.UI(appName)(Go builder). - Embed the bundle with
//go:embed all:web/dist. - Author the Svelte App.
- Build with
dockyard build— Vite first, thengo build.
Manifest declaration
apps:
- id: widgets
uri: ui://__PROJECT_NAME__/widgets/index.html
entry: web/src/App.svelte
display_modes: [inline]
csp:
connect: []
resource: []
visibility: [model, app]
quality:
require_loading_state: true
require_empty_state: true
require_error_state: true
require_permission_state: true
require_fixtures: truedisplay_modes: [inline] is the V1 default (decision D-126). The empty CSP lists pair with a single-file bundle (no external origins) and give you the deny-by-default posture by construction (RFC §7.4).
The quality: block turns on the four-state page rule (AGENTS.md §20): every page renders through loading / empty / error / permission / ready, and dockyard validate fails the build if a fixture is missing.
Wire the tool
tools:
- name: create_chart
description: Render a chart inline in the host.
input: internal/contracts.CreateChartInput
output: internal/contracts.CreateChartOutput
ui: widgets
task_support: forbiddenreturn tool.New[contracts.CreateChartInput, contracts.CreateChartOutput]("create_chart").
Describe("Render a chart inline in the host.").
UI(appName). // attach to the widgets App
Handler(handlers.CreateChart).
Register(srv)Embed + register
//go:embed all:web/dist
var uiBundle embed.FS
const (
appURI = "ui://__PROJECT_NAME__/widgets/index.html"
appName = "widgets"
)
func registerApp(srv *server.Server) error {
html, err := fs.ReadFile(uiBundle, "web/dist/index.html")
if err != nil {
return err
}
return apps.Register(srv, apps.App{
URI: appURI,
Name: appName,
Title: "__PROJECT_TITLE__ — widgets",
HTML: html,
})
}The all: prefix preserves hashed asset names and includes _ and . files. The single-file bundle pattern is preferred (one HTML file with inlined assets) — see the analytics-widgets template's Vite config for the canonical setup.
The ui:// URI is an opaque string
The html-style .../index.html path matches the reference MCP Apps SDK convention (D-178). Dockyard treats the URI as an opaque identifier, so the convention is documentation only — an existing project's ui://<server>/<app> URI keeps working; only the convention moved.
Dedicated origin (domain)
Leave App.Domain empty unless you have a specific reason to set it. The host then serves your App from its default per-conversation sandbox origin.
_meta.ui.domain is a host-supplied, verbatim value (D-176). The MCP Apps spec makes its format host-dependent: the host mints a dedicated sandboxed-iframe origin and documents it (e.g. a *.claudemcpcontent.com or *.oaiusercontent.com form); a server copies that exact string into App.Domain and Dockyard emits it byte-for-byte. Dockyard never synthesises or derives it.
A dedicated origin is honoured only on a remote (HTTP) connector — a local (stdio) connector ignores it. If you set Domain on a stdio-only server, Dockyard logs a loud startup warning naming the App; set it only for a verified remote deployment. (The former App.HostProfile / App.ServerURL fields are deprecated and ignored.)
Wire-compat: the deprecated flat tool-UI _meta key
Dockyard emits the canonical nested _meta.ui.resourceUri on a tool and, by default, never the deprecated flat form. For a host that still reads the flat key, opt in server-wide:
srv, _ := server.New(info, &server.Options{EmitLegacyToolUIMeta: true})Every UI-bearing tool registered through the tool.New(...).UI(...) builder then carries both keys (the flat value equals the nested resourceUri). Leave it off (the default) for RFC-compliant nested-only output — the 2026-01-26 spec marks the flat form deprecated. (D-177)
Author the Svelte App
<script lang="ts">
import { createBridge } from 'dockyard-bridge';
import { PageState, type PageStateValue } from 'dockyard-ui';
import Chart from './widgets/Chart.svelte';
import Table from './widgets/Table.svelte';
let pageState = $state<PageStateValue>('loading');
let payload = $state<{ kind: string } | null>(null);
// `displayModes` is advertised as appCapabilities.availableDisplayModes —
// keep it in sync with `display_modes` in dockyard.app.yaml.
const bridge = createBridge({ displayModes: ['inline'] });
// The callback receives a CallToolResult; the typed payload is on
// `structuredContent`.
bridge.onToolResult<{ kind: string }>((r) => {
payload = r.structuredContent ?? null;
pageState = payload ? 'ready' : 'error';
});
// Host theme variables (and the rest of hostContext) arrive here.
bridge.onHostContextChanged((p) => {
if (p.styles?.variables) applyHostVariables(p.styles.variables);
});
bridge.connect().catch(() => (pageState = 'error')); // run the handshake
</script>
<!-- PageState (dockyard-ui) covers loading / empty / error / ready. -->
<PageState state={pageState}>
{#if payload?.kind === 'chart'}
<Chart {...payload} />
{:else if payload?.kind === 'table'}
<Table {...payload} />
{/if}
</PageState>The host theme arrives via bridge.onHostContextChanged (p.styles.variables), or the reactive bridge.hostContext stores — apply it to your App's root. Compose the shared dockyard-ui inventory (PageState, DataTable, MetricCard, …) rather than rolling your own primitives.
Build + verify
dockyard build
dockyard validate
dockyard inspect --url <server> --dir .The inspector renders your App in a sandboxed iframe (the same CSP your manifest declares). The App preview fetches the ui:// resource via resources/read in a short-lived, operator-initiated MCP client session (decisions D-103, D-144); the inspector itself never executes server side-effects on its own — every mutating call comes from an explicit UI action.
Troubleshooting: a blank App in the host
If your App renders fine in the inspector but shows as a blank/white area in a host like Claude Desktop, work through these in order:
- Use a current
dockyard-bridge(≥ 1.7.0). Earlier bridge builds spoke a non-spec handshake that a strict host rejected (or deadlocked against), and never reported the App's content size — so the host sized the iframe to ~0px and the App looked blank with no error. The current bridge speaks the host'sui/dialect, signals readiness itself, and reports its size automatically (decisions D-179, D-180, D-181). - Check the iframe console for CSP errors. The App runs under a deny-by-default sandbox. A
Refused to connect/load …error means a domain your App reaches at runtime isn't declared — add it to the manifestcsp(connectforfetch/WebSocket,resourcefor scripts/styles/fonts/images). - Keep the tool result small. Very large results can fail to render; return only what the App needs as structured output.
Tasks×Apps is Dockyard-host-only
Live task progress (onTaskProgress) and the inline elicitation-response flow are Dockyard extensions — the ui/notifications/task-progress and ui/notifications/elicitation-response messages are not part of the MCP Apps schema. They work only against a Dockyard-aware host: the local inspector, or Harbor acting as the MCP client. A stock host (for example Claude Desktop) ignores them — progress never arrives and an elicitation reply is dropped (decision D-183). Design an App so its core value works without them, and treat progress and inline elicitation as enhancements that light up on a Dockyard host.