ext-apps/docs/quickstart.md
Jonathan Hefner 0571b56fbb Add Quickstart guide for building MCP Apps
Introduces a step-by-step tutorial covering:
- Project setup with Vite and TypeScript
- Server creation with tool + resource registration
- UI development using the App SDK
- Testing with basic-host

Also updates README to link to the guide and configures
typedoc to include it in generated documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 14:28:55 -06:00

7.1 KiB

title
Quickstart

Build Your First MCP App

This tutorial walks you through building an MCP App—a tool with an interactive UI that renders inside MCP hosts like Claude Desktop.

What You'll Build

A simple app that fetches the current server time and displays it in a clickable UI. You'll learn the core pattern: MCP Apps = Tool + UI Resource.

Prerequisites

1. Project Setup

Create a new directory and initialize:

mkdir my-mcp-app && cd my-mcp-app
npm init -y

Install dependencies:

npm install github:modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["*.ts", "src/**/*.ts"]
}

Create vite.config.ts — this bundles your UI into a single HTML file:

import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    outDir: "dist",
    rollupOptions: {
      input: process.env.INPUT,
    },
  },
});

Add to your package.json:

{
  "type": "module",
  "scripts": {
    "build": "INPUT=mcp-app.html vite build",
    "serve": "npx tsx server.ts"
  }
}

Full files: package.json, tsconfig.json, vite.config.ts

2. Create the Server

MCP Apps use a two-part registration:

  1. A tool that the LLM/host calls
  2. A resource that serves the UI HTML

The tool's _meta field links them together.

Create server.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps";
import cors from "cors";
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";

const server = new McpServer({
  name: "My MCP App Server",
  version: "1.0.0",
});

// Two-part registration: tool + resource
const resourceUri = "ui://get-time/mcp-app.html";

server.registerTool(
  "get-time",
  {
    title: "Get Time",
    description: "Returns the current server time.",
    inputSchema: {},
    outputSchema: { time: z.string() },
    _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, // Links tool to UI
  },
  async () => {
    const time = new Date().toISOString();
    return {
      content: [{ type: "text", text: time }],
      structuredContent: { time },
    };
  },
);

server.registerResource(resourceUri, resourceUri, {}, async () => {
  const html = await fs.readFile(
    path.join(import.meta.dirname, "dist", "mcp-app.html"),
    "utf-8",
  );
  return {
    contents: [{ uri: resourceUri, mimeType: "text/html+mcp", text: html }],
  };
});

// Express server for MCP endpoint
const app = express();
app.use(cors());
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3001, () => {
  console.log("Server listening on http://localhost:3001/mcp");
});

Full file: server.ts

3. Build the UI

Create mcp-app.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Get Time App</title>
  </head>
  <body>
    <p>
      <strong>Server Time:</strong> <code id="server-time">Loading...</code>
    </p>
    <button id="get-time-btn">Get Server Time</button>
    <script type="module" src="/src/mcp-app.ts"></script>
  </body>
</html>

Create src/mcp-app.ts:

import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";

// Get element references
const serverTimeEl = document.getElementById("server-time")!;
const getTimeBtn = document.getElementById("get-time-btn")!;

// Create app instance
const app = new App({ name: "Get Time App", version: "1.0.0" });

// Register handlers BEFORE connecting
app.ontoolresult = (result) => {
  const { time } = (result.structuredContent as { time?: string }) ?? {};
  serverTimeEl.textContent = time ?? "[ERROR]";
};

// Wire up button click
getTimeBtn.addEventListener("click", async () => {
  const result = await app.callServerTool({ name: "get-time", arguments: {} });
  const { time } = (result.structuredContent as { time?: string }) ?? {};
  serverTimeEl.textContent = time ?? "[ERROR]";
});

// Connect to host
app.connect(new PostMessageTransport(window.parent));

Build the UI:

npm run build

This produces dist/mcp-app.html containing your bundled app.

Full files: mcp-app.html, src/mcp-app.ts

4. Test It

You'll need two terminals.

Terminal 1 — Build and start your server:

npm run build && npm run serve

Terminal 2 — Run the test host (from the ext-apps repo):

git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps/examples/basic-host
npm install
npm run start

Open http://localhost:8080 in your browser:

  1. Select get-time from the "Tool Name" dropdown
  2. Click Call Tool
  3. Your UI renders in the sandbox below
  4. Click Get Server Time — the current time appears!

Next Steps