MCP Apps in your app
An MCP server's own interactive views, inside your Mac chat app — new in 2.0
Some MCP servers now ship more than tools: an MCP App is a small
interactive view the server provides alongside a tool — Todoist's task list, a system
monitor's live graphs. When a model calls that tool, a host that supports MCP Apps shows the
server's view right in the conversation, filled with the call's result, and the user can
work in it: tick a task, reschedule it, watch the numbers move. The LocalLM Lab SDK gives
your macOS app that support, with a local model doing the reasoning. This page starts with
the code, from the mcp-chat example, then explains what's underneath; the
MCP client and the SDK overview cover the rest.
Start from working code
mcp-chat is a complete chat app: Qwen3 8B
running on the Mac (or Apple's on-device model), MCP servers added from the
Components server list with per-server trust and approval, views shown inline,
streaming replies with collapsible reasoning, the date sent every turn, and conversations
that reopen where they left off. Open MCPChat.xcodeproj in Xcode 27 and Run, then
try it with Todoist (“what's due today?”) or a local system-monitor server.
MCPAppsHost is the package itself.
Adding MCP Apps to a chat app comes down to five steps. The code below is
mcp-chat's own, trimmed to the lines that matter here; each step links to the
full file.
1. Ask servers for their views
Servers only offer a view to a client that says it can show one, so declare it once, where you make the lab (ChatModel.swift):
// The lab makes the one MCP manager (lab.mcp). Declare MCP Apps support,
// so servers that only offer their views to hosts that can show them do.
let lab = LocalLMLab(configuration: .init(
providers: [mlx, SystemModelProvider()],
mcp: MCPSettings(handlers: MCPClientHandlers().advertisingMCPApps())))
lab.models.route(.chat, to: modelChoice.modelID)
2. A session that knows about views
mcpAppHints adds a line to each view-backed tool's description, so the model can
prefer it when the user asks to see something. The chat history to draw,
session.hostTranscript, fills in as the turn runs: your message, each tool call with
its result and the view it links, then the reply (ChatModel.swift):
var session = try lab.makeSession(
route: .chat, tools: builtInTools, instructions: Self.instructions,
mcpAppHints: true, // tell the model which tools also show the user a view
authorizer: authorizer) // a ConfirmingToolAuthorizer: asks the user before a tool runs
session.turnContext = { Self.clock(Date()) } // today's date and time with every message
// Sending a message. The conversation view draws from session.hostTranscript as the
// reply streams in, so the stream only needs draining here.
for try await _ in session.streamResponse(to: prompt) {}
3. Show a view under its tool call
A tool call in hostTranscript is a ToolCallRecord; its
app is set when the tool names a view (ChatViews.swift):
// In the conversation, under each finished tool call whose tool names a view:
if record.app != nil, record.state == .finished, record.mcpResult.map({ !$0.isError }) ?? false {
WidgetSlot(record: record, chat: chat, pool: chat.pool)
}
4. Fetch the view and run it
WidgetSlot fetches the view, makes its MCPAppViewController and hands it
the call's result (WidgetSlot.swift). After a relaunch, or when a
pooled view is re-created, it first asks MCPAppRecreation.revalidate whether the
stored result still fits the server's current view; that path is in the same file.
// First showing: fetch the server's view, keep it on disk by hash, bind it to this call.
let resource = try await chat.widgetCache.fetch(
resourceURI: uri, server: server, manager: chat.manager, recordID: record.id)
// A live view, from a pool that keeps a few alive (older ones show as snapshots).
let controller = pool.controller(for: record.id) {
MCPAppViewController(
resource: resource,
tools: chat.manager.servers[server]?.tools ?? [],
// The view's own tool calls go through the session: same authorizer, recorded in the chat.
backend: MCPAppsSessionBackend(session: session, manager: chat.manager,
server: server, instance: record.id),
configuration: MCPAppsBridgeConfiguration(
hostName: "MCP Chat", hostVersion: "0.1",
hostContext: ["displayMode": .string("inline")]),
bridgePolicy: ChatWidgetPolicy(viewOnly: false),
sandboxPolicy: .closed, // no network for the view unless you allow the hosts it declares
actions: .session(session, instance: record.id, openLink: { NSWorkspace.shared.open($0) }))
}
MCPAppRecreation.deliver(record, to: controller) // hand the view the call's arguments and result
await controller.load()
// In SwiftUI, sized to the view's own content:
MCPAppView(controller: controller)
.frame(height: max(80, controller.contentHeight ?? 360))
5. Decide what a view may do
On top of the standard's own rules, a bridge policy answers each request a view makes. Tool calls can be allowed here because the session's authorizer still asks the user, as it does for the model's calls (WidgetSlot.swift):
struct ChatWidgetPolicy: MCPAppsBridgePolicy {
let viewOnly: Bool
func authorizeToolCall(name: String, arguments: [String: MCPValue]) async -> MCPAppsDecision {
viewOnly ? .deny("Offline: this view is view-only") : .allow // the session's authorizer still asks the user
}
func authorizeOpenLink(_ url: URL) async -> MCPAppsDecision {
url.scheme?.lowercased() == "https" ? .allow : .deny("Only https links may be opened")
}
func authorizeMessage(text: String) async -> MCPAppsDecision { // send a message as the user
ChatSettings.allowWidgetMessages ? .allow : .deny("Widget messages are off in Settings")
}
func authorizeModelContextUpdate() async -> MCPAppsDecision { // share context with the model
ChatSettings.allowWidgetContext ? .allow : .deny("Widget context is off in Settings")
}
}
↑ Top
How a view comes up
The model never asks for a view. It picks a tool, the same way it always does — ask
“what's due today?” with Todoist connected, and it calls
find-tasks-by-date. That tool's listing names a view
(_meta.ui.resourceUri), so when the call finishes your app shows the view under
it, handed the call's arguments and result. The model gets the result as text, as usual.
- Steering the model toward these tools is optional:
makeSession(…, mcpAppHints: true)adds one line to such tools' descriptions saying the call also shows the user a view, so a model can prefer it when the user asks to see something. - Tools that exist only for a view (a dashboard's polling helper, marked app-only by the server) are never offered to the model.
- Servers only offer views to a client that says it can show them. One
setting does that:
LocalLMLab.Configuration(mcp: MCPSettings(handlers: MCPClientHandlers().advertisingMCPApps())).
What the SDK does for your app
LocalLMLabSDKMCPAppsHost is the host side of the MCP Apps standard, as a Swift
package:
- Rendering.
MCPAppViewController/MCPAppViewshow a view in a sandboxedWKWebView, sized to its content, with the host↔view messaging the standard defines. - A view's own tool calls go through your session
(
MCPAppsSessionBackend): ticking a task in Todoist's view is a tool call, checked by the same authorizer as the model's and recorded in the chat history as made by the view. A view can also send a message as the user or share context with the model — each only if you allow it. - Many views in one conversation. Each live view is a web process (about
100 MB).
MCPAppViewPoolkeeps a few live and shows older ones as snapshots until the user scrolls back;MCPAppWidgetCachekeeps each version of a view on disk by hash, so a view comes back after a relaunch or offline; andMCPAppRecreationdecides what to show for an old call — the current view when it still fits the stored result, a refresh when the tool's output changed shape, view-only when the server can't be reached.
It is open source (Apache 2.0) and built on the SDK's public API only, like
Components — read it, step into it, and see exactly how a third-party view
is contained.
A chat app to put it in
Views live in a conversation, so 2.0 also gives a chat app what it needs to draw one:
- A chat history built for display (
session.hostTranscript): your message as soon as you send it, every tool call as a live record with its full result and any view it links, the reply, and the model's reasoning split out from its answer (Qwen3's<think>text, for example) — in the order the model worked, and never shortened when the model's own context is compacted. - Streaming with
session.streamResponse(to:), the reply in progress available to your view as it's written. - The date, every turn. A model has no clock:
turnContextsends the current date and time with each message, so “what's due tomorrow?” means tomorrow. - Tools that follow the user. Turn on a server's tool, or add a server, and the model has it on its next turn — same conversation, no restart.
- Conversations that survive a relaunch: save what the user saw and, if you choose, the model's memory of it, and reopen both.
Third-party UI, contained
A view is HTML and script from someone else's server, running inside your app. The SDK treats it that way:
- Sandboxed and closed by default. No network access unless your policy allows the hosts a view declares; no camera, microphone or location; links open only if you allow them. The view's HTML is checked by hash, so a changed view is visible — and an app can pin the exact versions it approves.
- One approval path. A view's tool calls meet the same per-server trust and approval as the model's (SDK security). A dashboard that polls every two seconds asks once, not every two seconds: the user's answer is remembered for that view and tool, except for destructive tools, which ask each time.
- Visible to the user. Calls a view makes, messages it sends and context it gives the model are marked as coming from the view in the chat history.
Contact
Building on this, or hit a rough edge? neuron@thisbrain.ai or our Discord.
↑ Top