TLDR; MCP Apps are bringing interactive UIs to conversational agents and other MCP clients. While this official extension is still a but can bring new ways to interact with apps and content.
In this tutorial we will show how to create a simple yet powerful app (source code here) that can serve as a template for bigger projects. We will create an Express server (Node.js) using TypeScript, Vite and the 2 official MCP SDKs (TS server SDK and ext apps. The app will show the last flights that arrived to an airport in a nice way.
We will do this in 3 steps:
- Create an MCP server
- Register a tool
- Register a resource and connect to tool
Create an MCP server
We are going to the TypeScript SDK to generate a simple MCP Server using the Streamable HTTP transport (recommended way).
Let's start with the basics:
# Init project
npm init --y && npm pkg set type="module"
tsc --init
# Create gitignore file
echo -e "node_modules\ndist" > .gitignore
# Install deps
npm i @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps express zod && npm i -D @types/express nodemon
Our core logic will be included in our server.ts file:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { registerGetFlightsTool } from "./tools/get-flights.js";
import { registerFlightCardResource } from "./resources/flight-card/flight-card.js";
const server = new McpServer({
name: "My First MCP App",
version: "0.0.1",
});
// Set up Express server to handle MCP requests.
const app = express();
app.use(express.json());
app.use("/mcp", async (req, res, next) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => {
transport.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body).catch(next);
});
// Start the server.
app.listen(3000, () => {
console.log("MCP server listening on http://localhost:3000/mcp");
});
Then we want to create a pleasant development environment that serves the MCP server and reloads when we change a file. We are going to use nodemon for this. Create a nodemon.json file:
{
"watch": ["."],
"ext": "ts,html,css",
"ignore": ["dist", "node_modules"],
"exec": "tsc && node dist/server.js"
}
And adapt the package.json to add a new script
"dev": "nodemon"
Last but not least, we have to make sure that the complied files goes to the dist folder. In the tsconfig.json, uncomment the "outDir": "./dist", moment.
Here we go! We can now run our server:
npm run dev
We are going to use MCP Jam to test that our MCP server is running correctly:
npx @mcpjam/inspector@latest
And add a server with URL http://localhost:3000/mcp. The connection should be successful:
Bravo! Take a sip of coffee because you just finished the step 1 of this tutorial.
Register a tool to your MCP server
Our server is feeling pretty lonely without any tool or resource around! Let's create our first tool that is going to fetch the last flights from an airport.
We are going to return the next flights arriving at a specific airport. Our tool will receive the airport code as input and return an array of flight arrivals. For now we are mocking the flights response, but the point of those MCP tools is to connect to real-world APIs here.
We create a tools/get-flights.ts and register it in the main server.ts file.
// tools/get-flights.ts
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import z from "zod";
import { flightCardResourceUri } from "../resources/flight-card/flight-card.js";
export function registerGetFlightsTool(server: McpServer) {
server.registerTool(
"get-flights",
{
description: "retrieves flight arrivals for a given airport code",
inputSchema: {
code: z.string().describe("The ICAO airport code, e.g. 'KJFK'"),
},
},
async (input: { code: string }) => {
// Mock flight data for demonstration purposes. In a real implementation, you would fetch
// this data from an external API.
const mockFlights = [
{ flightNumber: "AA100", airline: "American Airlines" },
{ flightNumber: "DL200", airline: "Delta Airlines" },
{ flightNumber: "UA300", airline: "United Airlines" },
];
return {
content: [{ type: "text", text: JSON.stringify(mockFlights, null, 2) }],
structuredContent: { flights: mockFlights },
};
}
);
}
And register the tool in your server.ts file:
import { registerGetFlightsTool } from "./tools/get-flights.js";
[...]
const server = new McpServer({
name: "My First MCP App",
version: "0.0.1",
});
// Register the tool.
registerGetFlightsTool(server);
Our tool is now live and can be called dynamically in our chat playground. Cheers! You just end up the second part of this tutorial!
Register a UI resource and link it to our tool
Now we want to render the tool result in a UI within our chat application. Let's install more deps
npm i vite vite-plugin-singlefile glob
For now we just compiled TypeScript code into JavaScript but now we will use Vite to convert HTML too. We also want to use vite-singlefile plugin to gather the logic in the same file to return the whole MCP App in one request.
// vite.config.js
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
input: {
"flight-card": "resources/flight-card/mcp-app/flight-card-mcp-app.html",
// Add more HTML resources here as needed
},
},
},
});
Adapt the nodemon.json exec script adding the vite build command:
"exec": "vite build && tsc && node dist/server.js"
And let's declare the flight card resource:
tools/flight-card/flight-card.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";
// Define a unique URI for the flight card resource.
export const flightCardResourceUri = "ui://flight-card.html";
export function registerFlightCardResource(server: McpServer) {
server.registerResource(
flightCardResourceUri,
flightCardResourceUri,
{},
async () => {
// Read the HTML content from the file system.
const html = await fs.readFile(
path.join(import.meta.dirname, "mcp-app/flight-card-mcp-app.html"),
"utf-8"
);
return {
contents: [
{
uri: flightCardResourceUri,
mimeType: "text/html;profile=mcp-app",
text: html,
},
],
};
}
);
}
And register the resource in your server.ts file:
// server.ts
import { registerGetFlightsTool } from "./tools/get-flights.js";
import { registerFlightCardResource } from "./resources/flight-card/flight-card.js";
[...]
const server = new McpServer({
name: "My First MCP App",
version: "0.0.1",
});
// Register tools and resources.
registerGetFlightsTool(server);
registerFlightCardResource(server);
Now we can create the flight card MCP app, which will be an HTML template and a TS module. I created a new folder at /tools/flight-card/mcp-app to put those 2 files.
<!-- tools/flight-card/mcp-app/flight-card-mcp-app.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module" src="./flight-card-mcp-app.ts"></script>
<title>Flight Cards</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
background: #f0f2f5;
padding: 40px;
}
.container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
max-width: 1200px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 16px;
}
.icon {
width: 48px;
height: 48px;
background: #e8f4fd;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.info {
flex: 1;
}
.flight-number {
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.airline {
font-size: 14px;
color: #666;
margin-top: 4px;
}
</style>
</head>
<body>
<div class="container" id="flights"></div>
</body>
</html>
And the TS module:
// tools/flight-card/mcp-app/flight-card-mcp-app.ts
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
// Create a new MCP App instance.
const app = new App({
name: "Flight Card MCP App",
version: "0.0.1",
});
const flightsEl = document.getElementById("flights")!;
// Handle tool results to display flight information.
app.ontoolresult = (result) => {
const flights = result.structuredContent?.flights as {
flightNumber: string;
airline: string;
}[];
flights.forEach((flight) => {
flightsEl.innerHTML += `
<div class="card">
<div class="icon">✈️</div>
<div class="info">
<div class="flight-number">${flight.flightNumber}</div>
<div class="airline">${flight.airline}</div>
</div>
</div>
`;
});
};
app.connect(new PostMessageTransport(window.parent));
And last but not least, go back to your get-flights tool and add link to your resource using the _meta key.
// tools/get-flights.ts
import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps";
import { flightCardResourceUri } from "../resources/flight-card/flight-card.js";
[...]
server.registerTool(
"get-flights",
{
description: "retrieves flight arrivals for a given airport code",
inputSchema: {
code: z.string().describe("The ICAO airport code, e.g. 'KJFK'"),
},
// Link to resource here.
_meta: { [RESOURCE_URI_META_KEY]: flightCardResourceUri },
},
Bootsrapping all together, we are now able to see the UI appear when you ask the flights! Checkout the source GitHub repo
Personal thoughts
The MCP App extension is still a draft but we can already see where this is heading next: more focused and "intent-based" interfaces that deliver minimalistic yet effective experiences.
Of course, the browser experience is not going anywhere soon but we sense that many use cases can benefit from being ported to the chat format:
- UI-intensive user stories that could be accelerated through this format (like long multi-step forms)
- Brands that want to deliver rich experiences to tell a narrative despite website traffic loss
On the technical side we hope to see soon more MCP clients supporting this protocol.
What use cases are you seeing for this extension? Are you seeing some drawbacks or challenges? Leave your take on the comments.



Top comments (0)