-
Notifications
You must be signed in to change notification settings - Fork 5.9k
blog: add 5 tips for building MCP Apps that work #6855
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
0482326
306f90b
f7a2d3c
c205b49
b1005a1
e911b61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,351 @@ | ||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||
| title: "5 Tips for Building MCP Apps That Work" | ||||||||||||||||||||||||||||||
| description: "5 expert tips on building better MCP Apps for your AI agents" | ||||||||||||||||||||||||||||||
| authors: | ||||||||||||||||||||||||||||||
| - rizel | ||||||||||||||||||||||||||||||
| - matt | ||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|  | ||||||||||||||||||||||||||||||
|
blackgirlbytes marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) allow you to render interactive UI directly inside any agent supporting the Model Context Protocol. Instead of a wall of text, your agent can now provide a functional chart, a checkout form, or a video player. This bridges the gap in agentic workflows: clicking a button is often clearer than describing the action you hope an agent executes. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| MCP Apps originated as [MCP-UI](https://mcp-ui.dev/), an experimental project. After adoption by early clients like goose, the MCP maintainers incorporated it as an official extension. Today, it's supported by clients like goose, MCPJam, Claude, ChatGPT, and Postman. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Even though MCP Apps use web technologies, building one isn't the same as building a traditional web app. Your UI runs inside an agent you don't control, communicates with a model that can't see user interactions, and needs to feel native across multiple hosts. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| After implementing MCP App support in our own hosts and building several individual apps to run on them, here are the practical lessons we've picked up along the way. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| <!--truncate--> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ## Overview of how UI renders with MCP Apps | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| At a high level, clients that support MCP Apps load your UI via iFrames. Your MCP App exposes an MCP server with tools and resources. When the client wants to load your app's UI, it calls the associated MCP tool, loads the resource containing the HTML, then loads your HTML into an iFrame to display in the chat interface. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Here's an example flow of what happens when goose renders a cocktail recipe UI: | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 1. You ask the LLM "Show me a margarita recipe". | ||||||||||||||||||||||||||||||
| 2. The LLM calls the `get-cocktail` tool with the right parameters. This tool has a UI resource link in `_meta.ui.resourceUri` pointing to the resource containing the HTML. | ||||||||||||||||||||||||||||||
| 3. The client then uses the Uri to fetch the MCP resource. This resource contains the HTML content of the view. | ||||||||||||||||||||||||||||||
| 4. The HTML is then loaded into the iFrame directly in the chat interface, rendering the cocktail recipe. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|  | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| There's a lot that also goes on behind the scenes, such as widget hydration, capability negotiation, and CSPs, but this is how it works at a high level. If you're interested in the full implementation of MCP Apps, we highly recommend giving [the spec](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) a read. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ## Tip 1: Adapt to the Host Environment | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| When building an MCP App, you want it to feel like a natural part of the agent experience rather than something bolted on. Visual mismatches are one of the fastest ways to break that illusion. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Imagine a user starting an MCP App interaction inside a dark-mode agent, but the app renders in light mode and creates a harsh visual contrast. Even if the app works correctly, the experience immediately feels off. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| By default, your MCP App has no awareness of the surrounding agent environment because it runs inside a sandboxed iframe. It cannot tell whether the agent is in light or dark mode, how large the viewport is, or which locale the user prefers. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| The agent, referred to as the Host, solves this by sharing its environment details with your MCP App, known as the Guest UI. When the Guest UI connects, it sends a `ui/initialize` request. The Host responds with a `hostContext` object describing the current environment. When something changes, such as theme, viewport, or locale, the Host sends a `ui/notifications/host-context-changed` notification containing only the updated fields. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Imagine this dialogue between the Guest UI and Host: | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| > **Guest UI**: "I'm initializing. What does your environment look like?"<br/> | ||||||||||||||||||||||||||||||
| > **Host**: "We're in dark mode, viewport is 400×300, locale is en-US, and we're on desktop."<br/> | ||||||||||||||||||||||||||||||
| > *User switches to light theme*<br/> | ||||||||||||||||||||||||||||||
| > **Host**: "Update: we're now in light mode." | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| It is your job as the developer to ensure your MCP App makes use of the `hostContext` so it can adapt to the environment. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ### How to use hostContext in your MCP App | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ```ts | ||||||||||||||||||||||||||||||
| import { useState } from "react"; | ||||||||||||||||||||||||||||||
| import { useApp } from "@modelcontextprotocol/ext-apps/react"; | ||||||||||||||||||||||||||||||
| import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| function MyApp() { | ||||||||||||||||||||||||||||||
| const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>(undefined); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const { app, isConnected, error } = useApp({ | ||||||||||||||||||||||||||||||
| appInfo: { name: "MyApp", version: "1.0.0" }, | ||||||||||||||||||||||||||||||
| capabilities: {}, | ||||||||||||||||||||||||||||||
| onAppCreated: (app) => { | ||||||||||||||||||||||||||||||
| app.onhostcontextchanged = (ctx) => { | ||||||||||||||||||||||||||||||
| setHostContext((prev) => ({ ...prev, ...ctx })); | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (error) return <div>Error: {error.message}</div>; | ||||||||||||||||||||||||||||||
| if (!isConnected) return <div>Connecting...</div>; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||
| <p>Theme: {hostContext?.theme}</p> | ||||||||||||||||||||||||||||||
| <p>Locale: {hostContext?.locale}</p> | ||||||||||||||||||||||||||||||
| <p>Viewport: {hostContext?.containerDimensions?.width} x {hostContext?.containerDimensions?.height}</p> | ||||||||||||||||||||||||||||||
| <p>Platform: {hostContext?.platform}</p> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| :::tip | ||||||||||||||||||||||||||||||
| If you're using the `useApp` hook in your MCP App, the hook provides a `onhostcontextchanged` listener. You can then use a React `useState` to update your app context. The host will provide their context, it's up to you as the app developer to decide what you want to do with that. For example, you can use theme to render light mode vs dark mode, locale to show a different language, or containerDimensions to adjust the app's sizing. | ||||||||||||||||||||||||||||||
| ::: | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ## Tip 2: Control What the Model Sees and What the View Sees | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| There are cases where you may want to have granular control over what data the LLM has access to, and what data the view can show. The MCP Apps spec specifies three different tool return values that lets you control data flow, each are handled differently by the app host. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - `content`: Content is the info that you want to expose to the model. Gives model context. | ||||||||||||||||||||||||||||||
| - `structuredContent`: This data is hidden from the model context. It is used to send data over the View for hydration. | ||||||||||||||||||||||||||||||
| - `_meta`: This data is hidden from the model context. Used to provide additional info such as timestamps, version info. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Let's look at a practical example of how we can use these three tool return types effectively: | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ```ts | ||||||||||||||||||||||||||||||
| server.registerTool( | ||||||||||||||||||||||||||||||
| "view-cocktail", | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| title: "Get Cocktail", | ||||||||||||||||||||||||||||||
| description: "Fetch a cocktail by id with ingredients and images...", | ||||||||||||||||||||||||||||||
| inputSchema: z.object({ id: z.string().describe("The id of the cocktail to fetch.") }), | ||||||||||||||||||||||||||||||
| _meta: { | ||||||||||||||||||||||||||||||
| ui: { resourceUri: "ui://cocktail/cocktail-recipe-widget.html" }, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| async ({ id }: { id: string }): Promise<CallToolResult> => { | ||||||||||||||||||||||||||||||
| const cocktail = await convexClient.query(api.cocktails.getCocktailById, { | ||||||||||||||||||||||||||||||
| id, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||
| content: [ | ||||||||||||||||||||||||||||||
| { type: "text", text: `Loaded cocktail "${cocktail.name}".` }, | ||||||||||||||||||||||||||||||
| { type: "text", text: `Cocktail ingredients: ${cocktail.ingredients}".` }, | ||||||||||||||||||||||||||||||
| { type: "text", text: `Cocktail instructions: ${cocktail.instructions}".` }, | ||||||||||||||||||||||||||||||
|
blackgirlbytes marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||
| structuredContent: { cocktail }, | ||||||||||||||||||||||||||||||
| _meta: { timestamp: new Date().toString() } | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| This tool renders a view showing a cocktail recipe. The cocktail data is being fetched from the backend database (Convex). The View needs the entire cocktail data so we pass the data to it via `structuredContent`. For the model context, the LLM doesn't need to know the entire cocktail data like the image URL. We can extract the information that the model should know about the cocktail, like the name, ingredients, and instructions. That information can be passed to the model via `content`. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| It's important to note that currently, ChatGPT apps SDK handles it differently, where `structuredContent` is exposed to both the model and the View. Their model is the following: | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| - `content`: Content is the info that you want to expose to the model. Gives model context. | ||||||||||||||||||||||||||||||
| - `structuredContent`: This data is exposed to the model and the View. | ||||||||||||||||||||||||||||||
| - `_meta`: This data is hidden from the model context. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| If you're building an app that supports both MCP Apps and ChatGPT apps SDK, this is an important distinction. You may want to conditionally return values, or conditionally render tools based off of whether the client is MCP App support or ChatGPT app. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ## Tip 3: Properly Handle Loading States and Error States | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| It's pretty typical for the iFrame to render first before the tool finishes executing and the widget gets hydrated. You're going to want to let your user know that the app is loading by presenting a beautiful loading state. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|  | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| To implement this, let's take a look at the same cocktail recipes app. The MCP tool fetches the cocktail data and passes it to the widget via `structuredContent`. We don't know how long it takes to fetch that cocktail data, could be anywhere from a few ms to a few seconds on a bad day. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ```ts | ||||||||||||||||||||||||||||||
| server.registerTool( | ||||||||||||||||||||||||||||||
| "view-cocktail", | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| title: "Get Cocktail", | ||||||||||||||||||||||||||||||
| description: "Fetch a cocktail by id with ingredients and images...", | ||||||||||||||||||||||||||||||
| inputSchema: z.object({ id: z.string().describe("The id of the cocktail to fetch.") }), | ||||||||||||||||||||||||||||||
| _meta: { | ||||||||||||||||||||||||||||||
| ui: { resourceUri: "ui://cocktail/cocktail-recipe-widget.html" }, | ||||||||||||||||||||||||||||||
| visibility: ["model", "app"], | ||||||||||||||||||||||||||||||
|
blackgirlbytes marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| async ({ id }: { id: string }): Promise<CallToolResult> => { | ||||||||||||||||||||||||||||||
| const cocktail = await convexClient.query(api.cocktails.getCocktailById, { | ||||||||||||||||||||||||||||||
| id, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||
| content: [ | ||||||||||||||||||||||||||||||
| { type: "text", text: `Loaded cocktail "${cocktail.name}".` }, | ||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||
| structuredContent: { cocktail }, | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| On the view side (React), the `useApp` AppBridge hook has a `app.ontoolresult` listener that listens for the tool return results and hydrates your widget. While `onToolResult` hasn't come in yet and the data is empty, we can render a beautiful loading state. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
blackgirlbytes marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| ```ts | ||||||||||||||||||||||||||||||
| import { useApp } from "@modelcontextprotocol/ext-apps/react"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| function CocktailApp() { | ||||||||||||||||||||||||||||||
| const [cocktail, setCocktail] = useState<CocktailData | null>(null); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
Comment on lines
+182
to
+186
|
||||||||||||||||||||||||||||||
| useApp({ | ||||||||||||||||||||||||||||||
| appInfo: IMPLEMENTATION, | ||||||||||||||||||||||||||||||
| capabilities: {}, | ||||||||||||||||||||||||||||||
| onAppCreated: (app) => { | ||||||||||||||||||||||||||||||
| app.ontoolresult = async (result) => { | ||||||||||||||||||||||||||||||
| const data = extractCocktail(result); | ||||||||||||||||||||||||||||||
| setCocktail(data); | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return cocktail ? <CocktailView cocktail={cocktail} /> : <CocktailViewLoading />; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ### Handling errors | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| We also want to handle errors gracefully. In the case where there's an error in your tool, such as the cocktail data failing to load, both the LLM and the view should be notified of the error. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| In your MCP tool, you should return an `error` in the tool result. This is exposed to the model and also passed to the view. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ```ts | ||||||||||||||||||||||||||||||
| server.registerTool( | ||||||||||||||||||||||||||||||
| "view-cocktail", | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| title: "Get Cocktail", | ||||||||||||||||||||||||||||||
| description: "Fetch a cocktail by id with ingredients and images...", | ||||||||||||||||||||||||||||||
| inputSchema: z.object({ id: z.string().describe("The id of the cocktail to fetch.") }), | ||||||||||||||||||||||||||||||
| _meta: { | ||||||||||||||||||||||||||||||
| ui: { resourceUri: "ui://cocktail/cocktail-recipe-widget.html" }, | ||||||||||||||||||||||||||||||
| visibility: ["model", "app"], | ||||||||||||||||||||||||||||||
|
Comment on lines
+216
to
+217
|
||||||||||||||||||||||||||||||
| ui: { resourceUri: "ui://cocktail/cocktail-recipe-widget.html" }, | |
| visibility: ["model", "app"], | |
| ui: { resourceUri: "ui://cocktail/cocktail-recipe-widget.html", visibility: ["model", "app"] }, |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The catch block returns the raw error object from the backend to both the model and the MCP App view, which can expose internal details such as stack traces, database errors, or configuration information to end users. An attacker can trigger failing requests (e.g., invalid IDs or crafted payloads) to harvest these internal error messages and use them to map the backend or discover weaknesses. Instead, return a generic, user-safe error message and log the detailed error server-side, ensuring the tool result only contains sanitized error information.
| return { | |
| content: [ | |
| { type: "text", text: `Could not load cocktail` }, | |
| ], | |
| error | |
| // Log the detailed error server-side instead of returning it to the client. | |
| console.error("Failed to load cocktail", error); | |
| return { | |
| content: [ | |
| { type: "text", text: `Could not load cocktail` }, | |
| ], | |
| error: { | |
| message: "Failed to load cocktail.", | |
| }, |
Uh oh!
There was an error while loading. Please reload this page.