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 MIME text/html;profile=mcp-app

  • marks 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:

LawMeaning
Only narrowsThe result never grants more than the ask
IdempotentClamping twice by one allowance is clamping once
Identity and zeroHostPolicy.open changes nothing; HostPolicy.closed leaves an isolated view
MonotoneA wider allowance never yields a narrower result
Honest auditnarrowed 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:

EnumDirectionMessages
ViewRequestview to hostInitialize, OpenLink, DownloadFile, SendMessage, RequestDisplayMode, UpdateModelContext, CallTool, ReadResource, Ping
ViewNotificationview to hostInitialized, SizeChanged, RequestTeardown, Log
HostNotificationhost to viewToolInput, ToolInputPartial, ToolResult, ToolCancelled, HostContextChanged
HostRequesthost to viewResourceTeardown
SandboxMessagehost and relay onlyProxyReady, 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))