MCP Apps
An MCP App attaches a view to a tool. The server serves the view's HTML as a ui:// resource.
A host shows the tool's result in that view, inside a sandboxed iframe, and relays what the view
asks for over postMessage. Heddle's rule for all of it:
The view is untrusted. The shed is typed. The host decides.
flowchart LR
subgraph Server
E[Endpoints] --> T[tools with _meta.ui]
E --> R[ui:// view]
end
subgraph Host
P[policy clamp] --> F[sandboxed iframe]
end
subgraph View
V[Scala.js view] --> B[bridge]
end
R -- resources/read --> P
B -- tools/call, gated --> T
The view never names a tool with a string. It picks a grant from its shed, and every grant is
one of the same Endpoints the server binds.
The shed
A shed is one view's grants and the policy it asks for. Grant.launch is the tool the model
calls to open the view. Grant.app tools are the ones only the view calls, and the host keeps
them from the model. The tools are a named tuple, so a view calls _.show, never "get_show".
bill.grants.map(g => g.toolName -> g.visibility)List((list_shows,ModelAndApp), (get_show,App))Shed(UiUri("ui://box-office/bill"), "Bill", Grant.launch(BoxOffice.listShows))((show = 42))Every field of a shed's tools must be a Grant; Tuple1[Int] has one that is not..
I found:
heddle.mcp.apps.AllGrants.cons[G, T]
But given instance cons in object AllGrants does not match type heddle.mcp.apps.AllGrants[Tuple1[Int]].
Shed(UiUri("ui://box-office/bill"), "Bill", Grant.launch(BoxOffice.listShows))((show = 42))UiUri("https://box-office.example/bill")not a ui:// resource uri: a view's uri starts with ui://
expectFail("""UiUri("https://box-office.example/bill")""")Serving a view
mcp.withApp(shed, document) does the server's part:
serves the document as the
ui://resource, with MIMEtext/html;profile=mcp-appmarks every granted tool with
_meta.ui(the view's URI and who may call it)lists the view-only tools, which are not promoted to the model, with
visibility: ["app"]advertises the MCP Apps extension
UiDocument renders the HTML from the view's linked Scala.js script, so the server knows the
script's CSP hash by construction and publishes it for a host to pin. Read back through
Heddle's own client, exactly as a host would:
ZIO.scoped {
for
store <- BoxOffice.seed
mcp <- ZIO.fromEither(BoxOffice.mcpOf(store))
app <- ZIO.fromEither(mcp.withApp(bill, view))
host <- McpClient
.http("http://box-office.test/mcp", McpClient.Settings(Implementation("docs", "1")))
.provideSome[Scope](Client.inMemory(app.routes))
tools <- host.listTools
contents <- host.readResource(bill.uri.value)
yield
val marked = tools.map(t => t.name.value -> UiMeta.decodeTool(t.meta)._1.visibility).toMap
val html = contents.collectFirst { case t: ResourceContents.Text => t.text }
(marked, html.exists(_.contains("<div id=\"app\"></div>")))
}(Map(list_shows -> ModelAndApp, get_show -> App, seat_the_party -> ModelAndApp),true)What a view may ask for
A view asks for a UiPolicy: which origins it reaches for each network directive, browser
permissions, a stable origin, and whether it wants a border. UiPolicy.closed is the default:
no network, no permissions, a fresh opaque origin. A host allows a HostPolicy, and the view
gets the ask clamped by the allowance. The clamp only takes away, and narrowed says what it
took so the host can log it:
{
val weather = Origin("https://api.weather.example")
val tracker = Origin("https://tracker.example")
val ask = UiPolicy(Network(connect = Set(weather, tracker)), permissions = Set(Permission.Camera))
val host = HostPolicy(
NetworkAllowance(Admit.Only(Set(weather)), Admit.none, Admit.none, Admit.none),
permissions = Set.empty,
stable = StableOrigins.Refuse,
)
val clamp = Clamp[UiPolicy, HostPolicy]
(clamp.clamp(ask, host), clamp.narrowed(ask, host))
}(UiPolicy(Network(Set(Origin(Https,api.weather.example,None)),Set(),Set(),Set()),Set(),Opaque,HostDefault),Chunk(OriginDropped(Connect,Origin(Https,tracker.example,None)),PermissionDropped(Camera)))The clamp's laws are checked over generated policies:
| Law | Meaning |
|---|---|
| Only narrows | The result never grants more than the ask |
| Idempotent | Clamping twice by one allowance is clamping once |
| Identity and zero | HostPolicy.open changes nothing; HostPolicy.closed leaves an isolated view |
| Monotone | A wider allowance never yields a narrower result |
| Honest audit | narrowed is empty exactly when nothing was taken |
Reading any server's _meta.ui
A host reads _meta.ui from servers it does not control, so decoding is total and never
permissive. What it cannot trust, it drops and reports: a wildcard origin, a path, an unknown
permission, a bad ui:// URI. It also reads the legacy ui/resourceUri key, as the spec asks.
{
val meta =
"""{"ui":{"csp":{"connectDomains":["https://api.example.com","https://*.cdn.example.com"]},"permissions":{"camera":{},"teleport":{}}}}"""
ZIO.fromEither(meta.fromJson[Json.Obj]).map(obj => UiMeta.decodeResource(Some(obj)))
}(UiPolicy(Network(Set(Origin(Https,api.example.com,None)),Set(),Set(),Set()),Set(Camera),Opaque,HostDefault),Chunk(BadOrigin(Connect,https://*.cdn.example.com,'*' makes this more than scheme://host[:port]),UnknownPermission(teleport)))The view protocol
A view and its host speak JSON-RPC over postMessage: the SEP-1865 ui/* methods plus MCP's
own tools/call, resources/read, and ping. Heddle models each direction as a closed enum,
so a message is a value, and one heddle does not know is a typed refusal:
| Enum | Direction | Messages |
|---|---|---|
ViewRequest | view to host | Initialize, OpenLink, DownloadFile, SendMessage, RequestDisplayMode, UpdateModelContext, CallTool, ReadResource, Ping |
ViewNotification | view to host | Initialized, SizeChanged, RequestTeardown, Log |
HostNotification | host to view | ToolInput, ToolInputPartial, ToolResult, ToolCancelled, HostContextChanged |
HostRequest | host to view | ResourceTeardown |
SandboxMessage | host and relay only | ProxyReady, ResourceReady |
Each encodes to a JSON-RPC Message and decodes back from one. A ui/message must come from the
user, a download may carry only resources, and a tool name must be a tool name; anything else is
UiError.BadParams naming the method and the reason.
The host context arrives whole in the ui/initialize result, then in parts: a
host-context-changed notification names only what changed, and merge applies it. The standard
theme variables are the typed HostVar enum, container sizes are an Extent per side (fixed,
bounded, or free), and fields a newer host sends that this revision does not know are kept in
extra.
{
val start = """{"theme":"light","displayMode":"inline","containerDimensions":{"width":400,"maxHeight":600}}"""
val patch = """{"theme":"dark","styles":{"variables":{"--color-text-primary":"#fafafa"}}}"""
for
context <- start.fromJson[heddle.mcp.apps.ui.HostContext]
change <- patch.fromJson[heddle.mcp.apps.ui.HostContext]
yield context.merge(change)
}Right(HostContext(None,Some(Dark),Some(HostStyles(Map(ColorTextPrimary -> #fafafa),None)),Some(Inline),None,Some(ContainerDimensions(AtMost(600.0),Fixed(400.0))),None,None,None,None,None,None,{})){
import heddle.mcp.apps.ui.*
ViewRequest.decode("ui/message", Json.Obj("role" -> Json.Str("assistant"), "content" -> Json.Arr()))
}Left(ui/message: role must be user, not assistant)The view bridge
A view connects to its host with AppBridge.connect(shed, port, settings). The handshake is
ui/initialize and then ui/notifications/initialized; the bridge answers and routes messages
until its scope closes. ViewPort is the transport. In a browser it is
PostMessageBridge.toParent: the view posts to window.parent and hears only messages from it,
as the ext-apps SDK does, and anything that is not JSON-RPC is dropped. ViewPort.pair joins a
host and a view in one process, as the tests do. A host process that is not the page serves with
RemoteFrame: Mount.serve runs there, and the page's Frame.follow only frames and carries
FrameEvents.
ZIO.scoped {
for
bridge <- AppBridge.connect(bill, PostMessageBridge.toParent, AppBridge.Settings(Implementation("bill-view", "1")))
show <- bridge.call(_.show)(7) // Int in, Show out, ShowNotFound as a typed error
yield show
}
A view calls only its shed's grants, by picking them, and each call reuses the MCP client's
typed path: the input becomes the tool's arguments, and the result comes back as the endpoint's
output or one of its declared errors (McpCallFailure.Domain).
The view renders the launch tool's lifecycle from bridge.run, a stream of Run[In, Err, Out]
whose types come from the shed's launch grant. The host's notifications step it: a partial input
the model is still writing (shown, never trusted), the call, and its result, cancellation, or a
payload the launch tool's types cannot read. The host context arrives with the handshake and
changes through bridge.context. When the host tears the view down, the bridge runs the view's
onTeardown before it answers.
{
import heddle.mcp.apps.ui.*
for
called <- Run.step(BoxOffice.listShows)(Run.waiting, HostNotification.ToolInput(Json.Obj()))
early <- Run.step(BoxOffice.listShows)(
Run.waiting,
HostNotification.ToolResult(heddle.mcp.protocol.CallToolResult(Chunk.empty)),
)
yield (called, early)
}(Called(()),Unreadable(a tool result arrived before its input))