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.

↑ Top

What the SDK does for your app

LocalLMLabSDKMCPAppsHost is the host side of the MCP Apps standard, as a Swift package:

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.

↑ Top

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:

↑ Top

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:

↑ Top

Contact

Building on this, or hit a rough edge? neuron@thisbrain.ai or our Discord.

↑ Top