How to Build an MCP App
An MCP App is a tool that also shows an interactive page in the AI app's chat. Register the page as a ui:// resource, point your tool at it with _meta.ui.resourceUri, and write the page in HTML and TypeScript with the official @modelcontextprotocol/ext-apps package. The page receives the tool's result, and it can call your server's tools. The example below is a temperature converter: a server of about 90 lines and a page script of about 40 lines, both written in TypeScript.
What you'll have at the end
A server with two tools. show_temperature_converter opens a small converter in the chat, starting from a value you pass in, and returns the conversion as text. convert_temperature is for the view only: it does the arithmetic each time someone presses Convert. This is the result of calling convert_temperature from MCP Inspector:
{
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "temperature-converter",
"version": "1.0.0"
}
},
"content": [
{
"type": "text",
"text": "100 °C is 212 °F"
}
]
}
The view is an ordinary web page running in a sandboxed frame inside the chat. The host, not your server, decides how the frame is shown, and your server only supplies the HTML and the tool results.
Before you start
- Node.js 22.19 or later for MCP Inspector, which the test in step 4 uses. The
@modelcontextprotocol/ext-appspackage itself needs Node.js 20 or later. - Basic comfort with a terminal.
- Familiarity with MCP tools helps. If you're new to them, start with How to build an MCP server.
1. Set up the project
mkdir temperature-converter
cd temperature-converter
npm init -y
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client @modelcontextprotocol/server zod
npm install -D typescript @types/node@^22 vite vite-plugin-singlefile
mkdir -p src view
The ext-apps package has two halves. Its /server entry registers tools and resources with UI metadata, and its main entry gives the page an App class. The client package is a peer dependency of ext-apps, so install it next to it.
Replace package.json with this file so the build scripts and module type are set:
{
"name": "build-mcp-app",
"version": "1.0.0",
"type": "module",
"bin": {
"build-mcp-app": "./build/server.js"
},
"scripts": {
"build": "tsc && tsc -p view/tsconfig.json && vite build"
},
"files": ["build", "dist"],
"dependencies": {
"@modelcontextprotocol/client": "^2.3.1",
"@modelcontextprotocol/ext-apps": "^2.0.3",
"@modelcontextprotocol/server": "^2.3.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^7.0.2",
"vite": "^8.3.4",
"vite-plugin-singlefile": "^2.3.4"
}
}
Create tsconfig.json for the server:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Create view/tsconfig.json to type-check the page. It doesn't emit files, because Vite does the bundling:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["*.ts"]
}
Create vite.config.ts. It bundles the page and its script into one HTML file:
import { resolve } from "node:path";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
// Bundles view/converter.html and its script into one self-contained HTML file,
// so the server can return it as a single resource without extra CSP entries.
export default defineConfig({
root: "view",
plugins: [viteSingleFile()],
build: {
outDir: "../dist",
emptyOutDir: true,
rollupOptions: {
input: resolve("view", "converter.html"),
},
},
});
Bundling everything into one file matters for the security model. A host loads the page with a deny-by-default content policy, so a page that fetches a script or stylesheet from another URL is blocked unless you declare that origin. A single file avoids that.
2. Write the server
Create src/server.ts:
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import {
RESOURCE_MIME_TYPE,
registerAppResource,
registerAppTool,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const UI_URI = "ui://temperature-converter/converter.html";
// `npm run build` writes the bundled view to dist/, one level up from build/.
const VIEW_FILE = new URL("../dist/converter.html", import.meta.url);
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
async function readView(): Promise<string> {
try {
return await readFile(VIEW_FILE, "utf8");
} catch (error: unknown) {
throw new Error(`Could not read ${VIEW_FILE.pathname}. Run npm run build first.`, {
cause: error,
});
}
}
function convert(value: number, unit: "celsius" | "fahrenheit"): string {
const round = (n: number) => Math.round(n * 100) / 100;
if (unit === "celsius") {
return `${value} °C is ${round((value * 9) / 5 + 32)} °F`;
}
return `${value} °F is ${round(((value - 32) * 5) / 9)} °C`;
}
// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "temperature-converter", version: "1.0.0" });
// The model can open the app. The `ui` metadata tells the host which view to render.
// The text result carries the conversion too, for hosts that can't show the view.
registerAppTool(
server,
"show_temperature_converter",
{
title: "Show temperature converter",
description:
"Open an interactive temperature converter starting from a value, and return the conversion as text. Use this when the user wants to convert temperatures or try several values.",
inputSchema: z.object({
value: z.number().optional().describe("Starting temperature. Defaults to 20"),
unit: z
.enum(["celsius", "fahrenheit"])
.optional()
.describe("The unit that value is in. Defaults to celsius"),
}),
_meta: { ui: { resourceUri: UI_URI } },
},
async ({ value = 20, unit = "celsius" }) =>
text(`Opened the temperature converter. ${convert(value, unit)}`),
);
// Only the app can call this one. The model never sees it in its tool list.
registerAppTool(
server,
"convert_temperature",
{
title: "Convert temperature",
description: "Convert a temperature between Celsius and Fahrenheit.",
inputSchema: z.object({
value: z.number().describe("The temperature to convert"),
unit: z
.enum(["celsius", "fahrenheit"])
.describe("The unit that value is in"),
}),
_meta: { ui: { resourceUri: UI_URI, visibility: ["app"] } },
},
async ({ value, unit }) => text(convert(value, unit)),
);
registerAppResource(
server,
"Temperature converter",
UI_URI,
{ description: "Interactive temperature converter" },
async () => ({
contents: [{ uri: UI_URI, mimeType: RESOURCE_MIME_TYPE, text: await readView() }],
}),
);
return server;
}
// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("Temperature converter server error:", error),
});
console.error("Temperature converter MCP server running on stdio");
A few things in this file are specific to MCP Apps:
registerAppToolworks like the normal tool registration, but it sets_meta.ui.resourceUri. That tells the host which view to open when the tool runs. The helper also writes the older flatui/resourceUrikey so older hosts still work, which is whytools/listshows both keys.show_temperature_converterreturns the conversion as text as well as opening the view. A host that can't render the view still gives the model the answer. The spec describes this text-only fallback.visibility: ["app"]onconvert_temperaturekeeps it out of the model's tool list. Only the view can call it. The MCP Apps spec says a host must not show such a tool to the model, and must reject a view's call to a tool that doesn't include"app"in its visibility.registerAppResourceserves the page. Its MIME type must betext/html;profile=mcp-app, andRESOURCE_MIME_TYPEholds that value for you.createServerreturns a new server each time, the same as in the server guide. Standard output carries the protocol messages, so log to standard error only.
3. Build the view
The view is one HTML page and one script. Create view/converter.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temperature converter</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 16px;
}
form {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: end;
}
label {
display: flex;
flex-direction: column;
font-size: 14px;
}
input,
select,
button {
font: inherit;
padding: 6px 8px;
}
#result {
font-size: 18px;
margin-bottom: 0;
}
</style>
</head>
<body>
<form id="convert-form">
<label>
Value
<input id="value" type="number" step="any" value="20" required />
</label>
<label>
Unit
<select id="unit">
<option value="celsius">Celsius</option>
<option value="fahrenheit">Fahrenheit</option>
</select>
</label>
<button type="submit">Convert</button>
</form>
<p id="result" role="status">Enter a value and press Convert.</p>
<script type="module" src="./app.ts"></script>
</body>
</html>
Create view/app.ts:
import { App } from "@modelcontextprotocol/ext-apps";
function required<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) {
throw new Error(`Missing element: ${selector}`);
}
return element;
}
const form = required<HTMLFormElement>("#convert-form");
const valueInput = required<HTMLInputElement>("#value");
const unitSelect = required<HTMLSelectElement>("#unit");
const result = required<HTMLParagraphElement>("#result");
type ToolResult = { content?: Array<{ type: string; text?: string }> };
function showToolResult(toolResult: ToolResult): void {
const block = toolResult.content?.find((item) => item.type === "text");
result.textContent = block?.text ?? "The tool returned no text.";
}
const app = new App({ name: "Temperature converter", version: "1.0.0" }, {});
// Set handlers before connect() so the host can't push anything before we listen.
// The arguments are the ones the model used to open the view, so the form starts with them.
app.addEventListener("toolinput", (params) => {
const { value, unit } = params.arguments ?? {};
if (typeof value === "number") {
valueInput.value = String(value);
}
if (unit === "celsius" || unit === "fahrenheit") {
unitSelect.value = unit;
}
});
app.addEventListener("toolresult", (params) => showToolResult(params));
form.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const toolResult = await app.callServerTool({
name: "convert_temperature",
arguments: { value: valueInput.valueAsNumber, unit: unitSelect.value },
});
showToolResult(toolResult);
} catch (error: unknown) {
result.textContent =
error instanceof Error ? error.message : "The conversion failed.";
}
});
await app.connect();
The App class does the handshake with the host, so the page can start talking to it. Three calls do most of the work:
app.addEventListener("toolinput", ...)receives the arguments the model used to open the view, so the form can start with them.app.addEventListener("toolresult", ...)receives the result of the tool call that opened the view. Add both listeners beforeconnect(), so the host can't send anything before you're listening.app.callServerToolcalls a tool on your server through the host. This is how the form callsconvert_temperature.app.connect()starts the connection. Call it once.
You don't need a framework. The ext-apps examples include starter templates for React, Vue, Svelte, Preact, Solid and plain JavaScript, and the MCP Apps overview says you can use any framework or none.
4. Build and test with MCP Inspector
Build the server and the page:
npm run build
This writes build/server.js and dist/converter.html. If you skip the build, the server still starts, but reading the view fails with Could not read .../dist/converter.html. Run npm run build first.
Next, check the tools with the MCP Inspector command line. Run the list in the current protocol era, then in the legacy one:
npx @modelcontextprotocol/inspector@2.11.0 --cli node "$PWD/build/server.js" --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector@2.11.0 --cli node "$PWD/build/server.js" --protocol-era legacy --method tools/list
Both lists should include the two tools, and each one should carry the view's URI under _meta.ui. The modern list looks like this, trimmed:
{
"tools": [
{
"name": "show_temperature_converter",
...
"_meta": {
"ui": {
"resourceUri": "ui://temperature-converter/converter.html"
},
"ui/resourceUri": "ui://temperature-converter/converter.html"
}
},
{
"name": "convert_temperature",
...
"_meta": {
"ui": {
"resourceUri": "ui://temperature-converter/converter.html",
"visibility": [
"app"
]
},
"ui/resourceUri": "ui://temperature-converter/converter.html"
}
}
]
}
The Inspector's --app-info flag reads the UI metadata for each tool. It prints one JSON line per tool, and it exits with code 2 for a tool that has no view:
npx @modelcontextprotocol/inspector@2.11.0 --cli node "$PWD/build/server.js" --protocol-era modern --method tools/list --app-info
{"hasApp":true,"toolName":"show_temperature_converter","resourceUri":"ui://temperature-converter/converter.html","resourceMimeType":"text/html;profile=mcp-app"}
{"hasApp":true,"toolName":"convert_temperature","resourceUri":"ui://temperature-converter/converter.html","visibility":["app"],"resourceMimeType":"text/html;profile=mcp-app"}
Then read the view itself. The MIME type should be text/html;profile=mcp-app:
npx @modelcontextprotocol/inspector@2.11.0 --cli node "$PWD/build/server.js" --protocol-era modern --method resources/read --uri ui://temperature-converter/converter.html
Last, call the conversion. Here value and unit go in as --tool-arg pairs:
npx @modelcontextprotocol/inspector@2.11.0 --cli node "$PWD/build/server.js" --protocol-era modern --method tools/call \
--tool-name convert_temperature --tool-arg value=100 --tool-arg unit=celsius
The output contains "text": "100 °C is 212 °F", as shown at the top of this guide.
To see the view, run the Inspector's web interface on the same server. It opens in your browser:
npx @modelcontextprotocol/inspector node "$PWD/build/server.js"
Turn on the connection switch next to the server, then open the Apps tab and pick Show temperature converter. Click Open App. The view appears with a value of 20 and Celsius selected, and its result line shows the conversion that show_temperature_converter returned. Enter a value and press Convert, and the result line updates with the converted temperature.
5. Connect it to an AI app
To use the server in an AI app, add it to that app's MCP config. For a local server, the config is a command and its arguments. Save this as the app's config, with the path changed to your own build/server.js:
{
"mcpServers": {
"temperature-converter": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/build-mcp-app/typescript/build/server.js"]
}
}
}
The config file's location depends on the app. For Claude Desktop, see Claude Desktop, and restart the app after you save the file.
Whether the view appears in the chat depends on the app. The MCP Apps overview lists Claude, Claude Desktop, VS Code GitHub Copilot, Microsoft 365 Copilot, Goose, Postman, MCPJam and Archestra.AI as clients that support MCP Apps. ChatGPT also implements the MCP Apps standard, and OpenAI's docs describe the same _meta.ui.resourceUri setup. In an app without support, the model still gets the text from show_temperature_converter, so it can answer conversion questions without the view. convert_temperature is hidden from the model, so the Convert button only works where the view renders. See MCP Apps host compatibility for what each app supports.
If other people need the app, host the server remotely. See Deploy a remote MCP server.
Common problems
Could not read .../dist/converter.html. Run npm run build first.
The server started, but the page isn't built. Run npm run build. If you moved or deleted dist/, the build puts it back.
The tool runs, but no view opens.
The tool's _meta.ui.resourceUri doesn't match the URI the resource is registered under, or the resource is missing. Use registerAppTool and registerAppResource with the same ui:// string, and check with --app-info as shown in step 4. The host fetches the view with resources/read, so the resource has to exist on the server.
The view opens but is blank, or a script doesn't run.
The page loads something from another URL, such as a script, stylesheet, font or fetch call, and the host's content policy blocks it. The default policy allows no outside connections and only the page's own inline scripts and styles. Bundle everything into the HTML the way this guide does, or declare the origins in the resource's _meta.ui.csp.
Older examples use _meta["ui/resourceUri"] or text/html+mcp.
The flat _meta["ui/resourceUri"] key is deprecated, and the spec says it will be removed before GA. The MIME type in the spec is text/html;profile=mcp-app. Use _meta.ui.resourceUri and that MIME type, as this guide does.
FAQ
What happens in an app that can't show the view?
Keep the text result useful. The model reads it, and a host that doesn't render MCP Apps shows it to the person instead. That's why `show_temperature_converter` puts the conversion in its text.
Can I write the view in React?
Yes. The ext-apps package has React hooks for views, and its examples include React, Vue, Svelte, Preact, Solid and plain JavaScript starters. The App class in this guide works with any of them, or with none.
Can the view call any tool on my server?
Only tools whose visibility includes app. The host refuses a view's call to any other tool. A tool whose visibility is only app stays out of the model's tool list, so keep the model-facing tools for work the model should do.
Do MCP Apps work offline?
No. The view is created by a tool call, and it can only call your server while the AI app is connected to it.
- What are MCP AppsHow views, resources and the host bridge fit together.
- MCP App data flowHow tool results reach the view and how the view updates the model.
- Test and debug MCP AppsFix blank views, blocked scripts and missing resources.
- MCP Apps host compatibilityWhich AI apps render views and which show text only.
- How to build an MCP serverThe server basics this guide builds on.