Documentation
¶
Overview ¶
Package apps is the Dockyard server-side MCP Apps extension layer (io.modelcontextprotocol/ui, SEP-1865, spec revision 2026-01-26 — RFC §7).
An MCP App is not a new protocol primitive: it is a convention layered on a tool and a resource (brief 01 §2.1). This package implements the server half of that convention (RFC §7.1):
- it registers a ui:// resource carrying the App's HTML bundle, served with MIME type text/html;profile=mcp-app — the only MVP resource type;
- it attaches _meta.ui to the resources/read response — CSP, permissions, domain, prefersBorder — through a single choke point, because the MCP Apps spec reads CSP and domain from the read *response*, not only the static resource declaration (brief 01 §2.2, RFC §7.1);
- it builds the _meta.ui object that links a tool definition to its ui:// resource — the nested {resourceUri, visibility} form only, never the deprecated flat tool-UI _meta form;
- it advertises the io.modelcontextprotocol/ui extension capability with mimeTypes ["text/html;profile=mcp-app"] (RFC §7.1).
When no CSP is declared the encoded policy is deny-by-default — zero external origins — which is why generated apps default to single-file HTML bundles (RFC §7.4). A host may further restrict the policy but never loosen it.
Graceful degradation is mandatory and automatic: nothing in this package gates tool or resource registration on the host advertising the extension. A host that does not negotiate io.modelcontextprotocol/ui simply ignores _meta.ui and gets a fully working plain MCP server (RFC §7.1, §7.5).
Every MCP extension wire shape this package needs — the _meta.ui tool and resource objects, the extensions-capability block — is produced by internal/protocolcodec. Package apps constructs no raw extension wire JSON itself, preserving the protocolcodec isolation seam (P3, RFC §5.4).
Phase 10 adds convention-based UI auto-discovery and the embed pipeline on top of this surface, in new files that compose Register/App rather than rewriting them (RFC §7.6, §14):
- Discover walks the web/src/apps/ convention directory and lifts every .svelte file into a DiscoveredApp — no manual registration call;
- Bundle is an embed.FS-backed view of the built Svelte UI (//go:embed all:dist); one embed.FS backs the ui:// MCP resource handler;
- RegisterDiscovered registers a DiscoveredApp as a ui:// resource by composing Register, reading its HTML from the embedded Bundle.
The discovered tool↔UI wiring is written back into dockyard.app.yaml by internal/manifest.WriteDiscoveredApps, so the convention never hides the architecture (RFC §7.6).
_meta.ui.domain is the host-supplied dedicated origin (App.Domain), carried VERBATIM — the host mints the value and a server copies it; Dockyard never synthesises a host's signed origin (D-176, supersedes D-062/D-063). The pluggable host-profile seam (hostprofile.go) is retained for a future host-blessed transform but ships only the generic verbatim profile.
Out of scope for this package: the Svelte bridge shell (the View-half ui/ dialect).
Index ¶
- Constants
- Variables
- func DerivedDomain(hostProfileID, label, serverURL string) (string, error)
- func ExtensionCapability() (server.ExtensionCapability, error)
- func Register(s *server.Server, app App) error
- func RegisterDiscovered(s *server.Server, d DiscoveredApp, bundle Bundle) error
- func RegisterHostProfile(p HostProfile) error
- func RegisteredHostIDs() []string
- func ToolMetaFor(link ToolLink) (map[string]any, error)
- type App
- type Bundle
- type CSP
- type DiscoveredApp
- type HostProfile
- type Permissions
- type ToolLink
Constants ¶
const ( // VisibilityModel makes a tool callable by the agent/LLM. VisibilityModel = protocolcodec.VisibilityModel // VisibilityApp restricts a tool to same-server App-initiated calls — the // standard pattern for UI-only actions that should not pollute the model's // tool list (brief 01 §2.3). VisibilityApp = protocolcodec.VisibilityApp )
Visibility values for an Apps tool (_meta.ui.visibility). The default when the array is absent is both — a host treats an omitted visibility as ["model","app"] (brief 01 §2.3).
const ConventionDir = "web/src/apps"
ConventionDir is the path, relative to a Dockyard project root, under which a .svelte file is discovered by convention and registered as a ui:// resource (RFC §7.6). Keeping it a single constant means the convention is one edit to move, never a scattered literal.
const ExtensionID = protocolcodec.ExtensionApps
ExtensionID is the MCP Apps extension identifier, exactly as registered in the MCP capability registry (SEP-1865).
const MIMETypeApp = protocolcodec.MIMETypeApp
MIMETypeApp is the only MCP Apps resource MIME type defined by the MVP spec (text/html;profile=mcp-app). Routing it through one constant means a future profile type is a one-line change (brief 01 §5, sharp edge 5).
Variables ¶
var ErrBundleEntryNotFound = errors.New("dockyard/runtime/apps: bundle entry not found")
ErrBundleEntryNotFound is returned (wrapped) by Bundle.HTML when a discovered App's built HTML is not present in the embedded bundle.
var ErrEmptyBundle = errors.New("dockyard/runtime/apps: empty UI bundle")
ErrEmptyBundle is returned (wrapped) when a Bundle's embed target carries no built UI — the dist/ tree the //go:embed directive points at is absent or empty. Callers branch on it with errors.Is.
A missing dist/ directory makes the Go *build* fail at the //go:embed directive itself (the compiler cannot embed a path that does not exist); ErrEmptyBundle is the runtime-side analogue — a clean, typed failure when the directive resolved but the tree it embedded holds no files (RFC §14, the "build fails cleanly if the dist/ embed target is absent" criterion).
var ErrInvalidApp = errors.New("dockyard/runtime/apps: invalid App")
ErrInvalidApp is returned (wrapped) when an App declaration is malformed.
var ErrUnknownHost = errors.New("dockyard/runtime/apps: unknown host profile")
ErrUnknownHost is returned (wrapped) when a host id has no registered host profile. Callers can match it with errors.Is.
Functions ¶
func DerivedDomain ¶
DerivedDomain resolves a domain label through the host-profile seam (RFC §7.5). As of D-176 it is a VERBATIM passthrough: the only built-in profile is the "generic" one, which returns the label unchanged — `_meta.ui.domain` is a host-supplied value Dockyard never synthesises (D-176, supersedes D-062). The resources/read emission path no longer calls it (apps.go carries App.Domain verbatim directly); DerivedDomain is retained as the seam's read side for the testgate capability category and any direct caller, and for a future host-blessed transform registered behind RegisterHostProfile.
It resolves the host profile for hostProfileID (an empty id selects the "generic" verbatim profile), then runs that profile's DeriveDomain over the label and MCP server URL.
An empty label yields an empty origin and a nil error: the App declared no dedicated origin, so the runtime omits `_meta.ui.domain` entirely and a host reads the deny-by-default policy (RFC §7.4). An unregistered hostProfileID yields a wrapped ErrUnknownHost.
func ExtensionCapability ¶
func ExtensionCapability() (server.ExtensionCapability, error)
ExtensionCapability returns the server.ExtensionCapability that advertises the io.modelcontextprotocol/ui extension during the initialize handshake (RFC §7.1, brief 01 §2.7). It advertises mimeTypes ["text/html;profile=mcp-app"] — the single MVP resource type.
Pass the result in server.Options.Extensions when constructing the server:
cap, err := apps.ExtensionCapability()
srv, err := server.New(info, &server.Options{Extensions: []server.ExtensionCapability{cap}})
The capability JSON is produced by internal/protocolcodec; this package never hand-builds the wire shape (P3, RFC §5.4). A host that does not advertise the extension still gets a fully working plain MCP server — advertising it costs nothing and gates no behaviour (RFC §7.5).
func Register ¶
Register installs app as a ui:// resource on s (RFC §7.1). The resource is served with MIME type text/html;profile=mcp-app and every resources/read reply carries the App's _meta.ui — CSP, permissions, domain, prefersBorder — built through internal/protocolcodec.
Register must be called before the server runs. It returns a typed error (wrapping ErrInvalidApp) on a malformed App rather than panicking.
Register does not gate on the host advertising the Apps extension: a non-Apps host still gets the resource as a plain MCP resource and the App's tools work as plain tools (RFC §7.5). To link a tool to this App, pass ToolMetaFor's result as server.ToolDef.Meta when registering the tool.
func RegisterDiscovered ¶
func RegisterDiscovered(s *server.Server, d DiscoveredApp, bundle Bundle) error
RegisterDiscovered registers a discovered App as a ui:// resource on s, composing Register (Phase 09, RFC §7.1). The App's HTML body is read from the embedded bundle via Bundle.HTML — so the same //go:embed all:dist embed.FS backs the ui:// resource handler (RFC §14).
RegisterDiscovered carries the deny-by-default CSP (the zero apps.App.CSP): a discovered App is a single-file bundle with no declared external origins, so the secure default applies (RFC §7.4). A developer who needs a CSP opt-out edits the apps[] entry in dockyard.app.yaml and registers with Register.
func RegisterHostProfile ¶
func RegisterHostProfile(p HostProfile) error
RegisterHostProfile installs a host-profile driver in the process-wide registry. It is the factory entrypoint of the seam (AGENTS.md §4.4); built-in drivers call it from init() and an embedder may call it to add a profile.
It returns a typed error — never panics — on a nil profile, an empty ID, or a duplicate ID. Registration is idempotent only in the sense that a profile re-registering the *same* ID is rejected; replacing a profile is not allowed, so a driver cannot silently shadow another.
func RegisteredHostIDs ¶
func RegisteredHostIDs() []string
RegisteredHostIDs returns the sorted set of every registered host-profile id. It is the read side of the host-profile seam for callers that need to enumerate hosts — `dockyard test`'s capability-degradation category resolves every App through every registered profile, proving no host is special-cased outside the registry (CLAUDE.md §6 — never a hardcoded host matrix). The returned slice is a fresh copy and safe to retain.
func ToolMetaFor ¶
ToolMetaFor builds the tool-definition _meta map carrying _meta.ui for link (RFC §7.1, brief 01 §2.3). The encoding goes through internal/protocolcodec, which emits the nested {resourceUri, visibility} form. By default it never emits the deprecated flat tool-UI _meta form (P3, brief 01 §2.3); setting link.EmitLegacyResourceURI additionally writes the flat tool-UI key for a host that still reads it (D-177).
Use it when registering the App's tool:
meta, err := apps.ToolMetaFor(apps.ToolLink{ResourceURI: "ui://x/main"})
server.AddToolWithSchemas(s, server.ToolDef{Name: "x", Meta: meta}, ...)
A host that does not support the Apps extension simply ignores _meta.ui — the tool still works as a plain MCP tool (RFC §7.5).
Types ¶
type App ¶
type App struct {
// URI is the ui:// resource URI the App is served under. Required, and must
// use the ui:// scheme (brief 01 §2.2).
URI string
// Name is the programmatic resource identifier. Required.
Name string
// Title is the human-readable display name. Optional.
Title string
// Description is a hint surfaced to the model. Optional.
Description string
// HTML is the App's HTML document — the built single-file Svelte bundle.
// Required. Served as the text body of the resources/read response.
HTML []byte
// CSP is the App's Content-Security-Policy opt-out. The zero value is the
// secure deny-by-default policy — RFC §7.4.
CSP CSP
// Permissions is the App's sandbox-capability request. The zero value
// requests none.
Permissions Permissions
// Domain is the App's dedicated sandboxed-iframe origin (_meta.ui.domain) —
// a host-supplied, verbatim value. It is host-dependent and
// remote-connector-only: a developer copies the exact origin the host
// documents for a verified remote (HTTP) deployment — each host publishes
// its own format (commonly a hash-based or URL-derived subdomain) — and
// Dockyard emits it byte-for-byte on every resources/read. Dockyard does NOT
// synthesise or rewrite it — the host mints the value (the MCP Apps spec:
// "Servers MUST consult host-specific documentation for the expected domain
// format"; D-176, supersedes the auto-derivation of D-062/D-063). An empty
// Domain (the default) omits _meta.ui.domain, so the host uses its default
// per-conversation sandbox origin. Setting Domain on a stdio-only server has
// no effect on a local connector and logs a startup warning (RFC §7.5).
Domain string
// HostProfile is Deprecated: it no longer drives any domain derivation.
// _meta.ui.domain is now the verbatim, host-supplied Domain (D-176) — the
// host mints the origin and Dockyard never computes a host's signed form, so
// the field is ignored. It is retained for backward compatibility. The
// host-profile seam (RegisterHostProfile, the HostProfile interface) remains
// for a future host-blessed transform, but no built-in profile rewrites
// Domain.
HostProfile string
// ServerURL is Deprecated: it was the input a signing host profile derived a
// dedicated origin from. With derivation retired (D-176) it is ignored and
// retained only for backward compatibility.
ServerURL string
// PrefersBorder is the App's visual-boundary preference. A nil pointer
// declares none and lets the host decide.
PrefersBorder *bool
}
App is a server-side MCP App: a ui:// resource carrying an HTML bundle plus the host-facing _meta.ui metadata served on every resources/read of it (RFC §7.1). It is the unit apps.Register installs.
type Bundle ¶
type Bundle struct {
// contains filtered or unexported fields
}
Bundle is a read-only, embed.FS-backed view of the built Svelte UI — the dist/ tree produced by `vite build` and compiled into the Go binary via `//go:embed all:dist` (RFC §14, brief 06 §2.2).
A single Bundle (one embed.FS) backs the ui:// MCP resource handler; the same embed.FS also backs the inspector's HTTP preview (Phase 22) — there is never a second copy of the UI assets. A Bundle is immutable after NewBundle, so it is safe for concurrent use.
func EmbeddedBundle ¶
func EmbeddedBundle() Bundle
EmbeddedBundle returns the Bundle backed by the in-repo embedded dist/ tree. It is the reference wiring a generated project mirrors: build the Bundle once from the //go:embed FS, Validate it, then back every ui:// resource from it.
func NewBundle ¶
NewBundle returns a Bundle that serves the built UI rooted at root within fsys. root is the directory the //go:embed directive embedded — typically "dist". NewBundle does not touch the filesystem; call Validate to check the embed target is non-empty.
func (Bundle) HTML ¶
HTML returns the built HTML for a discovered App entry, read from the embedded bundle. entry is a DiscoveredApp.Entry — a "web/src/apps/<stem>.svelte" path; HTML maps it to the built artifact "<root>/<stem>.html" (the single-file bundle `vite build` emits per App, with vite-plugin-singlefile — RFC §7.4).
HTML never panics: a missing artifact is returned as an error wrapping ErrBundleEntryNotFound.
func (Bundle) Validate ¶
Validate reports whether the Bundle's embed target carries a built UI. It returns an error wrapping ErrEmptyBundle when the dist/ tree is absent or holds no regular files — the clean, typed failure RFC §14 requires instead of a panic. A nil return means the bundle has at least one built file.
type CSP ¶
type CSP struct {
// Connect are origins the App may open network connections to
// (fetch / XHR / WebSocket) — CSP connect-src.
Connect []string
// Resource are origins the App may load passive resources from — scripts,
// styles, images, fonts, media.
Resource []string
// Frame are origins the App may embed in nested iframes — CSP frame-src.
Frame []string
// BaseURI are document base URIs the App may declare — CSP base-uri.
BaseURI []string
}
CSP is an App's Content-Security-Policy opt-out: the external origins its deny-by-default policy is widened to allow (RFC §7.4, brief 01 §2.5). The zero value is the secure default — no external origins — and a single-file HTML bundle needs nothing more, so the deny-by-default CSP just works.
A host enforces the resulting CSP and may further restrict it, but never loosens it: an origin a host does not see declared here is denied.
type DiscoveredApp ¶
type DiscoveredApp struct {
// ID is the manifest-local identifier, derived from the file stem with
// hyphens normalised to underscores so it satisfies the manifest's
// identifier grammar (a tools[].ui reference targets this id).
ID string
// URI is the ui:// resource URI: ui://<manifestName>/<stem>.
URI string
// Entry is the .svelte source path relative to the project root, e.g.
// "web/src/apps/customer-health.svelte" — the manifest apps[].entry value.
Entry string
// Stem is the file stem (no extension) — the key Bundle.HTML maps to the
// built "<stem>.html" artifact.
Stem string
}
DiscoveredApp is one .svelte file found under ConventionDir, lifted to a registrable ui:// App. Its ID, URI, and Entry mirror a manifest apps[] entry (internal/manifest.App) so the discovered wiring can be written straight into dockyard.app.yaml — RFC §7.6's "convenience without hiding the architecture".
func Discover ¶
func Discover(root, manifestName string) ([]DiscoveredApp, error)
Discover walks ConventionDir under root and returns every .svelte file as a DiscoveredApp, sorted by ID for a deterministic result. manifestName is the manifest's `name` field — it forms the host segment of each ui:// URI.
A missing convention directory is not an error: a plain MCP server has no UI (RFC §7.1), so Discover returns an empty slice and a nil error. Discovery only reads the filesystem; it never registers anything or mutates the manifest — RegisterDiscovered and manifest.WriteDiscoveredApps do that.
type HostProfile ¶
type HostProfile interface {
// ID is the stable host identifier the profile registers under (e.g.
// "generic"). It must be non-empty and unique in the registry.
ID() string
// DeriveDomain derives the dedicated sandboxed-iframe origin for the host
// from a host-agnostic domain label and the MCP server URL.
//
// An empty label means the App declared no dedicated origin; a profile
// must then return "" with a nil error so the runtime omits
// `_meta.ui.domain` entirely (preserving the deny-by-default omission —
// RFC §7.4). The built-in "generic" profile returns the label verbatim;
// a future host-blessed profile may transform it.
DeriveDomain(label, serverURL string) (string, error)
// RequiresServerURL reports whether the profile cannot derive a domain
// from a non-empty label without a non-empty serverURL — the case of a
// signing host that binds the derivation to the server URL so distinct
// servers cannot forge each other's origin.
//
// A pass-through profile (e.g. "generic") returns false: it returns the
// label verbatim regardless of serverURL. A signing profile returns true:
// feeding it an empty serverURL yields an error rather than a forgeable
// origin.
//
// The method is the seam D-165 closes: it lets the
// capability-degradation testgate category exercise every profile
// honestly — a profile that requires a server URL is exempt from the
// empty-URL derivation (its derivation is proven by the profile's own
// tests, not synthesised in the gate) — without the gate fabricating a
// synthetic placeholder URL to dodge the invariant.
RequiresServerURL() bool
}
HostProfile is a pluggable bundle of host-specific *derivation functions* — algorithms, never a capability matrix (RFC §7.5, D-011, D-012). It is the extensibility seam (AGENTS.md §4.4) for a future host-blessed origin transform: a host that documents and blesses a server-side derivation can add a driver behind it without the Apps core naming any host.
As of D-176 the seam carries one built-in profile, "generic" (verbatim passthrough), and Dockyard emits `_meta.ui.domain` as the host-supplied App.Domain verbatim — the synthesising Claude profile (D-062/D-063) is retired because the MCP Apps spec makes `domain` a host-minted value a server copies, not one a framework computes. The seam stays so a legitimate, host-documented transform has a home.
New hosts are added as new driver files that self-register via init(); the core never enumerates hosts (brief 01 §4 sharp edge 3, §5).
func DefaultHostProfile ¶
func DefaultHostProfile() HostProfile
DefaultHostProfile returns the verbatim-passthrough "generic" host profile — the profile applied when an App selects no host (HostProfile == ""). It is always registered (hostprofile_generic.go init()).
func HostProfileFor ¶
func HostProfileFor(id string) (HostProfile, error)
HostProfileFor returns the registered host profile for id. An empty id resolves to the default ("generic") profile, so a caller that has not negotiated a host still gets verbatim, spec-faithful behaviour. An unregistered id yields a wrapped ErrUnknownHost.
type Permissions ¶
Permissions is an App's sandbox-capability request: the iframe permissions it asks the host to grant (RFC §7.4, brief 01 §2.5). Each is opt-in; the zero value requests none. A host may decline any of them.
type ToolLink ¶
type ToolLink struct {
// ResourceURI is the ui:// URI of the App resource the tool renders into.
// Required, and must use the ui:// scheme.
ResourceURI string
// Visibility is who may invoke the tool: any of VisibilityModel /
// VisibilityApp. An empty slice means "unspecified" — a host treats that as
// ["model","app"]. Set ["app"] for a UI-only action tool (brief 01 §2.3).
Visibility []string
// EmitLegacyResourceURI additionally emits the DEPRECATED flat tool-UI
// _meta key alongside the canonical nested `_meta.ui.resourceUri` (D-177).
// The default (false) is RFC-compliant nested-only output (RFC §7.1, brief
// 01 §2.3). The runtime/tool builder sets it from the server-level
// Options.EmitLegacyToolUIMeta opt-in; a direct ToolMetaFor caller may set
// it for a host that still reads the flat key. It is a wire-compat escape
// hatch, not a recommended posture. The flat key's literal lives only in
// internal/protocolcodec (P3).
EmitLegacyResourceURI bool
}
ToolLink links a tool definition to a ui:// resource — the tool side of an MCP App (brief 01 §2.3). Pass ToolMetaFor(link) as server.ToolDef.Meta.