How Data Flows Between an MCP App, the Host and the Model
The host sends the view the tool's arguments and its result, so the view doesn't have to ask for its data. The arguments arrive as ui/notifications/tool-input and the result as ui/notifications/tool-result. After that the view can call tools on the same server, send a message into the chat, or update what the model knows with ui/update-model-context.
Who holds what
An MCP App has four parties. Each one sees a different slice of the data, and most of the confusion about MCP Apps comes from mixing them up.
| Party | What it does | What it sees |
|---|---|---|
| MCP server | Runs the tool and serves the ui:// page | The tool arguments and the page request |
| Host | The AI app. It fetches the page, renders it in a sandboxed iframe and passes messages between the view and the server | Everything. It decides what reaches the model |
| View | The HTML page, running in the iframe | The tool arguments and result the host sends it |
| Model | The language model inside the host | The content text from tool results, plus anything the view sends with ui/update-model-context or ui/message |
The view never talks to the server directly. Every request goes through the host, which can check it, block it or ask the user first.
The rest of this guide walks through the diagram one step at a time.
Step 1: The host calls the tool and fetches the view
A tool that has a view points at a ui:// resource in its _meta.ui block. This is the tool entry the server returns from tools/list:
{
"name": "get_weather",
"description": "Get current weather for a location",
"inputSchema": {
"type": "object",
"properties": {
"location": { "type": "string" }
}
},
"_meta": {
"ui": {
"resourceUri": "ui://weather-server/dashboard-template",
"visibility": ["model", "app"]
}
}
}
The host can fetch the page before the tool runs, which lets it stream the arguments into the view as the model writes them. Either way, the host gets the HTML with resources/read. The spec requires the MIME type text/html;profile=mcp-app:
{
"contents": [
{
"uri": "ui://weather-server/dashboard-template",
"mimeType": "text/html;profile=mcp-app",
"text": "<!DOCTYPE html><html>...</html>",
"_meta": {
"ui": {
"csp": {
"connectDomains": ["https://api.openweathermap.org"],
"resourceDomains": ["https://cdn.jsdelivr.net"]
},
"prefersBorder": true
}
}
}
]
}
The csp block lists the outside origins the view may load from or connect to. If a resource leaves it out, the host applies a default that blocks all network connections. Hosts can tighten these rules but can't loosen them.
Step 2: The view connects
When the iframe loads, the view starts a handshake with the host. With the official SDK, the view creates an App and calls connect():
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Weather view", version: "1.0.0" });
app.connect();
Under the hood, connect() sends a ui/initialize request. The host replies with its capabilities and a hostContext object. That object carries the theme, the current displayMode, the containerDimensions the view has to fit in, and toolInfo for the tool call that opened the view. The view then sends ui/notifications/initialized. The spec says the host must not send the view any request or notification before that notification arrives.
Register your event listeners before you call connect(). The SDK docs say this avoids missing notifications.
Step 3: The host sends the arguments and the result
The host pushes the tool's data to the view. The view doesn't poll for it. There are four notifications, and the spec sets rules for when each one is sent:
| Notification | Sent when | What it carries |
|---|---|---|
ui/notifications/tool-input-partial | Zero or more times while the model is still writing the arguments | Best-effort arguments, with unclosed brackets closed |
ui/notifications/tool-input | Once, after the view's initialize finishes | The complete arguments object |
ui/notifications/tool-result | When the tool finishes, if the view is on screen | The CallToolResult: content, structuredContent, isError |
ui/notifications/tool-cancelled | When the tool is cancelled for any reason | A reason string |
The SDK maps these to events you can listen for with addEventListener. toolinput, toolinputpartial, toolresult and toolcancelled are the event names:
app.addEventListener("toolinput", (params) => {
const output = document.getElementById("output")!;
output.textContent = `Loading weather for ${params.arguments?.location ?? "your location"}...`;
});
app.addEventListener("toolresult", (params) => {
const output = document.getElementById("output")!;
output.textContent = params.isError
? "The tool failed. Try again."
: JSON.stringify(params.structuredContent);
});
The result notification looks like this on the wire. Note that structuredContent is for the view to draw with, and content is the text the model sees:
{
"jsonrpc": "2.0",
"method": "ui/notifications/tool-result",
"params": {
"content": [{ "type": "text", "text": "Current weather: Sunny, 72°F" }],
"structuredContent": { "temperature": 72, "conditions": "sunny", "humidity": 45 }
}
}
The spec describes the two fields this way. content is the text representation for the model and for hosts that can't render the view. structuredContent is data for rendering, and the spec says it is not added to the model's context.
Step 4: The view calls tools again
When the user clicks something, the view can call a tool on the server that sent it. It uses callServerTool, and the host forwards the request as a normal tools/call:
const result = await app.callServerTool({
name: "refresh_dashboard",
arguments: {},
});
if (result.isError) {
console.error("Refresh failed:", result.content);
}
Two outcomes are different here. A tool that ran and failed comes back as a result with isError: true. A request that never reached the tool, such as a timeout or a rejected call, throws an error. Check both.
Not every tool should be callable from the view. The visibility field on the tool controls who can use it:
["model", "app"]is the default. The model and the view can both call the tool.["app"]hides the tool from the model. The view can still call it, which suits buttons such as refresh. The spec says the host must leave such a tool out of the model's tool list and must rejecttools/callfrom the view for any tool that doesn't include"app".
{
"name": "refresh_dashboard",
"description": "Refresh dashboard data",
"inputSchema": { "type": "object" },
"_meta": {
"ui": {
"resourceUri": "ui://weather-server/dashboard-template",
"visibility": ["app"]
}
}
}
A view can only reach tools on the server that sent it, so cross-server calls are blocked for app-only tools. The host can also require the user to approve a view's request.
The spec describes the refresh result as going back to the view. It doesn't say the model sees it. If the model needs the new numbers to answer the next question, the view has to send them, as the next step shows.
Step 5: The view updates what the model knows
ui/update-model-context lets the view leave notes for the model. It takes content for text and structuredContent for machine-readable data:
await app.updateModelContext({
content: [
{ type: "text", text: "The user is viewing 3 overdue invoices, oldest first." },
],
});
The spec sets these rules for the host:
- Each update replaces the previous one from the same view.
- The host should give the context to the model in future turns, but it may hold it back until the user's next message.
- If several updates arrive before the next message, the host should send only the last one.
- The host may show the update to the user.
Notice the difference from tool-result. structuredContent in a tool result is for drawing the view and stays out of the model's context. structuredContent in a context update is data for the model.
Step 6: The view sends a message into the chat
ui/message adds a message to the conversation as if the user had typed it, and the host follows up on it:
const result = await app.sendMessage({
role: "user",
content: [{ type: "text", text: "Show me the details for invoice 42" }],
});
if (result.isError) {
console.error("The host did not accept the message.");
}
The host should add the message to the conversation, and it may ask the user for consent first. A rejected message comes back with isError: true.
Large data should go through updateModelContext, not into the message. The SDK docs show the pattern: send the full data as model context, then send a short message that asks the model to act on it. The message stays small, and the model still gets everything.
Display modes, teardown and size
A view can run in three display modes: inline, fullscreen and pip (picture-in-picture). The view declares the modes it supports when it creates the App:
const app = new App(
{ name: "Weather view", version: "1.0.0" },
{ availableDisplayModes: ["inline", "fullscreen"] },
);
To switch, the view calls requestDisplayMode. The host can keep the current mode, so read the mode in the response instead of assuming the request worked:
const { mode } = await app.requestDisplayMode({ mode: "fullscreen" });
The host won't switch to a mode the view didn't declare. It tells the view about mode changes through ui/notifications/host-context-changed.
Before the host removes the view, it sends ui/resource-teardown, and the spec says it should wait for the reply. Use the onteardown handler to save state:
app.onteardown = async () => {
sessionStorage.setItem("draft", draftInput.value);
return {};
};
The view also reports its own size. With the SDK's default autoResize setting, it sends ui/notifications/size-changed whenever its content changes size, so you don't need to write that code yourself.
Common misconceptions
"The view has to fetch its data." It doesn't need to. The host pushes the arguments and the result. The view calls callServerTool only when it needs fresh data.
"Anything in structuredContent reaches the model." The spec says the opposite for tool results. Put anything the model must know in content, or send it with ui/update-model-context.
"Calling updateModelContext changes the model's next reply right away." The host decides when the model sees the update. It may wait for the next user message.
"A view can call any tool on any server." A view can only call tools on the server that sent it, and only tools whose visibility includes app.
"Hosts that don't support MCP Apps show nothing." They show the tool's normal content text. That's why the spec asks servers to return a meaningful text result even when a view exists.
FAQ
Does the view get the tool result without asking for it?
Yes. The host sends the result to the view as a ui/notifications/tool-result message after the tool finishes. The view only calls a tool itself when it needs new data.
Does the model see the structuredContent from an MCP App tool?
No. The spec says structuredContent is for rendering the view and is not added to the model's context. Put anything the model needs in the content array.
How does an MCP App give the model new information after the user clicks something?
The view sends it with ui/update-model-context. The host gives that context to the model in a later turn, and it may wait for the user's next message first.
Can an MCP App call a tool that the model cannot see?
Yes. A tool with visibility set to app is hidden from the model and can only be called by the view from the same server. It suits buttons such as refresh.
- What are MCP AppsThe ui:// resource, the sandboxed view and the host bridge in one overview.
- Build an MCP AppMake a working tool with an interactive view using the official SDK.
- Tool outputsText, structured content and outputSchema, and when each one is the right choice.
- MCP Apps host compatibilityWhich AI apps render MCP Apps views and what each one supports.
- Test and debug MCP AppsFix the usual problems with CSP, sandboxing and missing resources.