diff --git a/.prettierignore b/.prettierignore
index dd3c59b..f5edcf9 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,5 +2,6 @@ examples/basic-host/**/*.ts
examples/basic-host/**/*.tsx
examples/basic-server-*/**/*.ts
examples/basic-server-*/**/*.tsx
+examples/quickstart/**/*.ts
**/vendor/**
SKILL.md
diff --git a/docs/quickstart-success.png b/docs/quickstart-success.png
new file mode 100644
index 0000000..058fb3f
Binary files /dev/null and b/docs/quickstart-success.png differ
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 9481df3..63e9371 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -4,200 +4,351 @@ 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.
+This tutorial walks you through building an MCP App—a tool with an interactive **View** (a UI that renders inside an iframe) that displays in 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**.
+A simple app that fetches the current server time and displays it in an interactive View. You'll learn the core pattern: **MCP Apps = Tool + UI Resource**.
> [!NOTE]
-> The complete example is available at [`examples/basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs).
+> The complete example is available at [`examples/quickstart`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/quickstart).
## Prerequisites
-- Familiarity with MCP concepts, especially [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) and [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources)
-- Familiarity with the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
-- Node.js 18+
+This tutorial assumes you've built an MCP server before and are comfortable with [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) and [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources). If not, the [official MCP quickstart](https://modelcontextprotocol.io/docs/develop/build-server) is a good place to start.
-> [!TIP]
-> New to building MCP servers? Start with the [official MCP quickstart guide](https://modelcontextprotocol.io/docs/develop/build-server) to learn the core concepts first.
+We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) to build the server.
-## 1. Project Setup
+You'll also need Node.js 18+.
-Create a new directory and initialize:
+## 1. Set up the project
+
+We'll set up a minimal TypeScript project with Vite for bundling.
+
+Start by creating a project directory:
```bash
mkdir my-mcp-app && cd my-mcp-app
-npm init -y
```
-Install dependencies:
+Install the dependencies you'll need:
```bash
-npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
-npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx
+npm init -y
+npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors
+npm install -D typescript vite vite-plugin-singlefile @types/express @types/cors @types/node tsx concurrently cross-env
```
-Create `tsconfig.json`:
+Configure your [`package.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/package.json):
-```json
+```bash
+npm pkg set type=module
+npm pkg set scripts.build="tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build"
+npm pkg set scripts.start="concurrently 'cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch' 'tsx watch main.ts'"
+```
+
+
+Create tsconfig.json:
+
+
+```json source="../examples/quickstart/tsconfig.json"
{
"compilerOptions": {
- "target": "ES2022",
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
"strict": true,
- "esModuleInterop": true,
"skipLibCheck": true,
- "outDir": "dist"
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
},
- "include": ["*.ts", "src/**/*.ts"]
+ "include": ["src", "server.ts", "main.ts"]
}
```
-Create `vite.config.ts` — this bundles your UI into a single HTML file:
+
-```typescript
+
+Create tsconfig.server.json — for compiling server-side code:
+
+
+```json source="../examples/quickstart/tsconfig.server.json"
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "outDir": "./dist",
+ "rootDir": ".",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["server.ts", "main.ts"]
+}
+```
+
+
+
+
+Create vite.config.ts — bundles UI into a single HTML file:
+
+
+```ts source="../examples/quickstart/vite.config.ts"
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
+const INPUT = process.env.INPUT;
+if (!INPUT) {
+ throw new Error("INPUT environment variable is not set");
+}
+
+const isDevelopment = process.env.NODE_ENV === "development";
+
export default defineConfig({
plugins: [viteSingleFile()],
build: {
- outDir: "dist",
+ sourcemap: isDevelopment ? "inline" : undefined,
+ cssMinify: !isDevelopment,
+ minify: !isDevelopment,
+
rollupOptions: {
- input: process.env.INPUT,
+ input: INPUT,
},
+ outDir: "dist",
+ emptyOutDir: false,
},
});
```
-Add to your `package.json`:
+
-```json
-{
- "type": "module",
- "scripts": {
- "build": "INPUT=mcp-app.html vite build",
- "serve": "npx tsx server.ts"
- }
-}
+Your `my-mcp-app` directory should now contain:
+
+```
+my-mcp-app/
+├── package.json
+├── tsconfig.json
+├── tsconfig.server.json
+└── vite.config.ts
```
-> [!NOTE]
-> **Full files:** [`package.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/package.json), [`tsconfig.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/tsconfig.json), [`vite.config.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/vite.config.ts)
+With the project scaffolded, let's write the server code.
-## 2. Create the Server
+## 2. Register the tool and UI resource
MCP Apps use a **two-part registration**:
1. A **tool** that the LLM/host calls
-2. A **resource** that serves the UI HTML
+2. A **resource** that contains the View HTML
-The tool's `_meta` field links them together.
+The tool's `_meta` field links them together via the resource's URI. When an MCP Apps-capable host calls the tool, it will also read the resource and render the View.
-Create `server.ts`:
+Create [`server.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/server.ts), which registers the tool and its UI resource:
-```typescript
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+
+```ts source="../examples/quickstart/server.ts"
import {
- registerAppTool,
registerAppResource,
+ registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
-import cors from "cors";
-import express from "express";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";
-const server = new McpServer({
- name: "My MCP App Server",
- version: "1.0.0",
-});
+const DIST_DIR = path.join(import.meta.dirname, "dist");
-// Two-part registration: tool + resource, tied together by the resource URI.
-const resourceUri = "ui://get-time/mcp-app.html";
-
-// Register a tool with UI metadata. When the host calls this tool, it reads
-// `_meta.ui.resourceUri` to know which resource to fetch and render as an
-// interactive UI.
-registerAppTool(
- server,
- "get-time",
- {
- title: "Get Time",
- description: "Returns the current server time.",
- inputSchema: {},
- _meta: { ui: { resourceUri } },
- },
- async () => {
- const time = new Date().toISOString();
- return {
- content: [{ type: "text", text: time }],
- };
- },
-);
-
-// Register the resource, which returns the bundled HTML/JavaScript for the UI.
-registerAppResource(
- server,
- resourceUri,
- resourceUri,
- { mimeType: RESOURCE_MIME_TYPE },
- async () => {
- const html = await fs.readFile(
- path.join(import.meta.dirname, "dist", "mcp-app.html"),
- "utf-8",
- );
- return {
- contents: [
- { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
- ],
- };
- },
-);
-
-// Start an Express server that exposes the MCP endpoint.
-const expressApp = express();
-expressApp.use(cors());
-expressApp.use(express.json());
-
-expressApp.post("/mcp", async (req, res) => {
- const transport = new StreamableHTTPServerTransport({
- sessionIdGenerator: undefined,
- enableJsonResponse: true,
+/**
+ * Creates a new MCP server instance with tools and resources registered.
+ */
+export function createServer(): McpServer {
+ const server = new McpServer({
+ name: "Quickstart MCP App Server",
+ version: "1.0.0",
});
- res.on("close", () => transport.close());
- await server.connect(transport);
- await transport.handleRequest(req, res, req.body);
-});
-expressApp.listen(3001, (err) => {
- if (err) {
- console.error("Error starting server:", err);
- process.exit(1);
+ // Two-part registration: tool + resource, tied together by the resource URI.
+ const resourceUri = "ui://get-time/mcp-app.html";
+
+ // Register a tool with UI metadata. When the host calls this tool, it reads
+ // `_meta.ui.resourceUri` to know which resource to fetch and render as an
+ // interactive UI.
+ registerAppTool(
+ server,
+ "get-time",
+ {
+ title: "Get Time",
+ description: "Returns the current server time.",
+ inputSchema: {},
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
+ },
+ async () => {
+ const time = new Date().toISOString();
+ return { content: [{ type: "text", text: time }] };
+ },
+ );
+
+ // Register the resource, which returns the bundled HTML/JavaScript for the UI.
+ registerAppResource(
+ server,
+ resourceUri,
+ resourceUri,
+ { mimeType: RESOURCE_MIME_TYPE },
+ async () => {
+ const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
+
+ return {
+ contents: [
+ { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
+ ],
+ };
+ },
+ );
+
+ return server;
+}
+```
+
+
+Create main.ts — the entry point that starts the server:
+
+
+```ts source="../examples/quickstart/main.ts"
+import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import cors from "cors";
+import type { Request, Response } from "express";
+import { createServer } from "./server.js";
+
+/**
+ * Starts an MCP server with Streamable HTTP transport in stateless mode.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance per request.
+ */
+export async function startStreamableHTTPServer(
+ createServer: () => McpServer,
+): Promise {
+ const port = parseInt(process.env.PORT ?? "3001", 10);
+
+ const app = createMcpExpressApp({ host: "0.0.0.0" });
+ app.use(cors());
+
+ app.all("/mcp", async (req: Request, res: Response) => {
+ const server = createServer();
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ });
+
+ res.on("close", () => {
+ transport.close().catch(() => {});
+ server.close().catch(() => {});
+ });
+
+ try {
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error("MCP error:", error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: "2.0",
+ error: { code: -32603, message: "Internal server error" },
+ id: null,
+ });
+ }
+ }
+ });
+
+ const httpServer = app.listen(port, (err) => {
+ if (err) {
+ console.error("Failed to start server:", err);
+ process.exit(1);
+ }
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
+ });
+
+ const shutdown = () => {
+ console.log("\nShutting down...");
+ httpServer.close(() => process.exit(0));
+ };
+
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+}
+
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
+async function main() {
+ if (process.argv.includes("--stdio")) {
+ await startStdioServer(createServer);
+ } else {
+ await startStreamableHTTPServer(createServer);
}
- console.log("Server listening on http://localhost:3001/mcp");
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
});
```
-> [!NOTE]
-> **Full file:** [`server.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/server.ts)
+
-Then, verify your server compiles:
+Your `my-mcp-app` directory should now contain:
+
+```
+my-mcp-app/
+├── main.ts
+├── package.json
+├── server.ts
+├── tsconfig.json
+├── tsconfig.server.json
+└── vite.config.ts
+```
+
+Let's verify everything compiles:
```bash
npx tsc --noEmit
```
-No output means success. If you see errors, check for typos in `server.ts`.
+No output means success! If you see errors, check for typos in `server.ts` or `main.ts`.
-## 3. Build the UI
+The server can return the current time when the tool is called. Now let's build the UI to display it.
-Create `mcp-app.html`:
+## 3. Build the View
-```html
+The View consists of an HTML page and a script that connects to the host.
+
+Create [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/mcp-app.html), the HTML for your View:
+
+
+```html source="../examples/quickstart/mcp-app.html"
@@ -214,9 +365,10 @@ Create `mcp-app.html`:
```
-Create `src/mcp-app.ts`:
+Create [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/src/mcp-app.ts), which connects to the host and handles user interactions:
-```typescript
+
+```ts source="../examples/quickstart/src/mcp-app.ts"
import { App } from "@modelcontextprotocol/ext-apps";
// Get element references
@@ -226,7 +378,8 @@ 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
+// Handle tool results from the server. Set before `app.connect()` to avoid
+// missing the initial tool result.
app.ontoolresult = (result) => {
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]";
@@ -234,6 +387,7 @@ app.ontoolresult = (result) => {
// Wire up button click
getTimeBtn.addEventListener("click", async () => {
+ // `app.callServerTool()` lets the UI request fresh data from the server
const result = await app.callServerTool({ name: "get-time", arguments: {} });
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]";
@@ -243,30 +397,50 @@ getTimeBtn.addEventListener("click", async () => {
app.connect();
```
-> [!NOTE]
-> **Full files:** [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/mcp-app.html), [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts)
+Your `my-mcp-app` directory should now contain:
-Build the UI:
+```
+my-mcp-app/
+├── main.ts
+├── mcp-app.html
+├── package.json
+├── server.ts
+├── src/
+│ └── mcp-app.ts
+├── tsconfig.json
+├── tsconfig.server.json
+└── vite.config.ts
+```
+
+Now let's build the bundled View:
```bash
npm run build
```
-This produces `dist/mcp-app.html` which contains your bundled app:
+This produces `dist/mcp-app.html`:
```console
-$ ls dist/mcp-app.html
-dist/mcp-app.html
+$ ls dist/
+mcp-app.html
```
-## 4. Test It
+The View will connect to the host, receive the tool result, and display it. Let's see it in action!
-You'll need two terminals.
+## 4. See it in action
-**Terminal 1** — Build and start your server:
+You'll need two terminal windows.
+
+**Terminal 1** — Start your server (with watch mode):
```bash
-npm run build && npm run serve
+npm start
+```
+
+You should see:
+
+```console
+MCP server listening on http://localhost:3001/mcp
```
**Terminal 2** — Run the test host (from the [ext-apps repo](https://github.com/modelcontextprotocol/ext-apps)):
@@ -275,18 +449,23 @@ npm run build && npm run serve
git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps/examples/basic-host
npm install
-npm run start
+npm 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
+3. Your View renders in the sandbox below
4. Click **Get Server Time** — the current time appears!
+
+
+You've built your first MCP App!
+
## Next Steps
-- **Host communication**: Add [`sendMessage()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), and [`sendOpenLink()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendopenlink) to interact with the host — see [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts)
+- **Continue learning**: The [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) example builds on this quickstart with host communication, theming, and lifecycle handlers
- **React version**: Compare with [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) for a React-based UI
+- **Other frameworks**: See also [Vue](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vue), [Svelte](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-svelte), [Preact](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-preact), and [Solid](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-solid) examples
- **API reference**: See the full [API documentation](https://modelcontextprotocol.github.io/ext-apps/api/)
diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css
index eb4d679..ec18e2b 100644
--- a/examples/basic-host/src/index.module.css
+++ b/examples/basic-host/src/index.module.css
@@ -145,11 +145,11 @@
}
.appIframePanel {
- min-height: 200px;
+ min-height: 100px;
iframe {
width: 100%;
- height: 600px;
+ height: 400px;
box-sizing: border-box;
border: 3px dashed var(--color-border);
border-radius: 4px;
diff --git a/examples/basic-server-preact/server.ts b/examples/basic-server-preact/server.ts
index 2d99964..bf14ea5 100644
--- a/examples/basic-server-preact/server.ts
+++ b/examples/basic-server-preact/server.ts
@@ -29,7 +29,7 @@ export function createServer(): McpServer {
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {},
- _meta: { ui: { resourceUri } },
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-react/server.ts b/examples/basic-server-react/server.ts
index 416b217..23f6dda 100644
--- a/examples/basic-server-react/server.ts
+++ b/examples/basic-server-react/server.ts
@@ -30,7 +30,7 @@ export function createServer(): McpServer {
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {},
- _meta: { ui: { resourceUri } },
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-solid/server.ts b/examples/basic-server-solid/server.ts
index 33f76a1..2ed2d93 100644
--- a/examples/basic-server-solid/server.ts
+++ b/examples/basic-server-solid/server.ts
@@ -29,7 +29,7 @@ export function createServer(): McpServer {
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {},
- _meta: { ui: { resourceUri } },
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-svelte/server.ts b/examples/basic-server-svelte/server.ts
index dd2dba6..da603ca 100644
--- a/examples/basic-server-svelte/server.ts
+++ b/examples/basic-server-svelte/server.ts
@@ -29,7 +29,7 @@ export function createServer(): McpServer {
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {},
- _meta: { ui: { resourceUri } },
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts
index 07e82c2..5b2daf7 100644
--- a/examples/basic-server-vanillajs/server.ts
+++ b/examples/basic-server-vanillajs/server.ts
@@ -33,7 +33,7 @@ export function createServer(): McpServer {
outputSchema: z.object({
time: z.string(),
}),
- _meta: { ui: { resourceUri } },
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-vue/server.ts b/examples/basic-server-vue/server.ts
index 73cb54d..5aa72de 100644
--- a/examples/basic-server-vue/server.ts
+++ b/examples/basic-server-vue/server.ts
@@ -29,7 +29,7 @@ export function createServer(): McpServer {
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {},
- _meta: { ui: { resourceUri } },
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/quickstart/.gitignore b/examples/quickstart/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/examples/quickstart/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md
new file mode 100644
index 0000000..74e070d
--- /dev/null
+++ b/examples/quickstart/README.md
@@ -0,0 +1,3 @@
+# Quickstart Server
+
+This is the example code for the [Quickstart guide](../../docs/quickstart.md).
diff --git a/examples/quickstart/main.ts b/examples/quickstart/main.ts
new file mode 100644
index 0000000..06895a4
--- /dev/null
+++ b/examples/quickstart/main.ts
@@ -0,0 +1,87 @@
+import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import cors from "cors";
+import type { Request, Response } from "express";
+import { createServer } from "./server.js";
+
+/**
+ * Starts an MCP server with Streamable HTTP transport in stateless mode.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance per request.
+ */
+export async function startStreamableHTTPServer(
+ createServer: () => McpServer,
+): Promise {
+ const port = parseInt(process.env.PORT ?? "3001", 10);
+
+ const app = createMcpExpressApp({ host: "0.0.0.0" });
+ app.use(cors());
+
+ app.all("/mcp", async (req: Request, res: Response) => {
+ const server = createServer();
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ });
+
+ res.on("close", () => {
+ transport.close().catch(() => {});
+ server.close().catch(() => {});
+ });
+
+ try {
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error("MCP error:", error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: "2.0",
+ error: { code: -32603, message: "Internal server error" },
+ id: null,
+ });
+ }
+ }
+ });
+
+ const httpServer = app.listen(port, (err) => {
+ if (err) {
+ console.error("Failed to start server:", err);
+ process.exit(1);
+ }
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
+ });
+
+ const shutdown = () => {
+ console.log("\nShutting down...");
+ httpServer.close(() => process.exit(0));
+ };
+
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+}
+
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
+async function main() {
+ if (process.argv.includes("--stdio")) {
+ await startStdioServer(createServer);
+ } else {
+ await startStreamableHTTPServer(createServer);
+ }
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/examples/quickstart/mcp-app.html b/examples/quickstart/mcp-app.html
new file mode 100644
index 0000000..bf276aa
--- /dev/null
+++ b/examples/quickstart/mcp-app.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Get Time App
+
+
+
+ Server Time: Loading...
+
+
+
+
+
diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json
new file mode 100644
index 0000000..86933d4
--- /dev/null
+++ b/examples/quickstart/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@modelcontextprotocol/quickstart",
+ "version": "0.4.1",
+ "type": "module",
+ "private": true,
+ "description": "Quickstart MCP App Server example",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "directory": "examples/quickstart"
+ },
+ "license": "MIT",
+ "scripts": {
+ "build": "tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build",
+ "start": "concurrently 'cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch' 'tsx watch main.ts'"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/ext-apps": "^0.4.1",
+ "@modelcontextprotocol/sdk": "^1.24.0",
+ "cors": "^2.8.5",
+ "express": "^5.1.0"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "concurrently": "^9.2.1",
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.0.0",
+ "cross-env": "^10.1.0",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3",
+ "vite": "^6.0.0",
+ "vite-plugin-singlefile": "^2.3.0"
+ }
+}
diff --git a/examples/quickstart/server.ts b/examples/quickstart/server.ts
new file mode 100644
index 0000000..eb3c3b1
--- /dev/null
+++ b/examples/quickstart/server.ts
@@ -0,0 +1,60 @@
+import {
+ registerAppResource,
+ registerAppTool,
+ RESOURCE_MIME_TYPE,
+} from "@modelcontextprotocol/ext-apps/server";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import fs from "node:fs/promises";
+import path from "node:path";
+
+const DIST_DIR = path.join(import.meta.dirname, "dist");
+
+/**
+ * Creates a new MCP server instance with tools and resources registered.
+ */
+export function createServer(): McpServer {
+ const server = new McpServer({
+ name: "Quickstart MCP App Server",
+ version: "1.0.0",
+ });
+
+ // Two-part registration: tool + resource, tied together by the resource URI.
+ const resourceUri = "ui://get-time/mcp-app.html";
+
+ // Register a tool with UI metadata. When the host calls this tool, it reads
+ // `_meta.ui.resourceUri` to know which resource to fetch and render as an
+ // interactive UI.
+ registerAppTool(
+ server,
+ "get-time",
+ {
+ title: "Get Time",
+ description: "Returns the current server time.",
+ inputSchema: {},
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
+ },
+ async () => {
+ const time = new Date().toISOString();
+ return { content: [{ type: "text", text: time }] };
+ },
+ );
+
+ // Register the resource, which returns the bundled HTML/JavaScript for the UI.
+ registerAppResource(
+ server,
+ resourceUri,
+ resourceUri,
+ { mimeType: RESOURCE_MIME_TYPE },
+ async () => {
+ const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
+
+ return {
+ contents: [
+ { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
+ ],
+ };
+ },
+ );
+
+ return server;
+}
diff --git a/examples/quickstart/src/mcp-app.ts b/examples/quickstart/src/mcp-app.ts
new file mode 100644
index 0000000..5780bb7
--- /dev/null
+++ b/examples/quickstart/src/mcp-app.ts
@@ -0,0 +1,26 @@
+import { App } 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" });
+
+// Handle tool results from the server. Set before `app.connect()` to avoid
+// missing the initial tool result.
+app.ontoolresult = (result) => {
+ const time = result.content?.find((c) => c.type === "text")?.text;
+ serverTimeEl.textContent = time ?? "[ERROR]";
+};
+
+// Wire up button click
+getTimeBtn.addEventListener("click", async () => {
+ // `app.callServerTool()` lets the UI request fresh data from the server
+ const result = await app.callServerTool({ name: "get-time", arguments: {} });
+ const time = result.content?.find((c) => c.type === "text")?.text;
+ serverTimeEl.textContent = time ?? "[ERROR]";
+});
+
+// Connect to host
+app.connect();
diff --git a/examples/quickstart/tsconfig.json b/examples/quickstart/tsconfig.json
new file mode 100644
index 0000000..6c553b5
--- /dev/null
+++ b/examples/quickstart/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src", "server.ts", "main.ts"]
+}
diff --git a/examples/quickstart/tsconfig.server.json b/examples/quickstart/tsconfig.server.json
new file mode 100644
index 0000000..7e65f5f
--- /dev/null
+++ b/examples/quickstart/tsconfig.server.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "outDir": "./dist",
+ "rootDir": ".",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["server.ts", "main.ts"]
+}
diff --git a/examples/quickstart/vite.config.ts b/examples/quickstart/vite.config.ts
new file mode 100644
index 0000000..6ff6d99
--- /dev/null
+++ b/examples/quickstart/vite.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "vite";
+import { viteSingleFile } from "vite-plugin-singlefile";
+
+const INPUT = process.env.INPUT;
+if (!INPUT) {
+ throw new Error("INPUT environment variable is not set");
+}
+
+const isDevelopment = process.env.NODE_ENV === "development";
+
+export default defineConfig({
+ plugins: [viteSingleFile()],
+ build: {
+ sourcemap: isDevelopment ? "inline" : undefined,
+ cssMinify: !isDevelopment,
+ minify: !isDevelopment,
+
+ rollupOptions: {
+ input: INPUT,
+ },
+ outDir: "dist",
+ emptyOutDir: false,
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index b3d9490..9907c20 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -568,6 +568,45 @@
"@modelcontextprotocol/ext-apps": "^0.4.1"
}
},
+ "examples/quickstart": {
+ "name": "@modelcontextprotocol/quickstart",
+ "version": "0.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/ext-apps": "^0.4.1",
+ "@modelcontextprotocol/sdk": "^1.24.0",
+ "cors": "^2.8.5",
+ "express": "^5.1.0"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.0.0",
+ "concurrently": "^9.2.1",
+ "cross-env": "^10.1.0",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3",
+ "vite": "^6.0.0",
+ "vite-plugin-singlefile": "^2.3.0"
+ }
+ },
+ "examples/quickstart/node_modules/@types/node": {
+ "version": "22.19.7",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
+ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "examples/quickstart/node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"examples/say-server": {
"name": "@modelcontextprotocol/server-say",
"version": "0.4.1",
@@ -897,6 +936,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -2333,11 +2373,16 @@
"resolved": "examples/basic-host",
"link": true
},
+ "node_modules/@modelcontextprotocol/quickstart": {
+ "resolved": "examples/quickstart",
+ "link": true
+ },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz",
"integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -3417,6 +3462,7 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@@ -3623,6 +3669,7 @@
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3647,6 +3694,7 @@
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4049,6 +4097,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4390,6 +4439,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5105,6 +5155,7 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -5577,6 +5628,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -7126,6 +7178,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -7335,6 +7388,7 @@
"integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -7456,6 +7510,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
}
@@ -7746,6 +7801,7 @@
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz",
"integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.5.0",
@@ -7924,6 +7980,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz",
"integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -9013,6 +9070,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9106,6 +9164,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -9400,6 +9459,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.27",
"@vue/compiler-sfc": "3.5.27",
@@ -9560,6 +9620,7 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
+ "peer": true,
"bin": {
"yaml": "bin.mjs"
},
@@ -9610,6 +9671,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts
index 0f2d8ec..420da93 100644
--- a/tests/e2e/servers.spec.ts
+++ b/tests/e2e/servers.spec.ts
@@ -17,6 +17,7 @@ const DYNAMIC_MASKS: Record = {
"cohort-heatmap": ['[class*="heatmapWrapper"]'], // Heatmap grid (random data)
"customer-segmentation": [".chart-container"], // Scatter plot (random data)
"debug-server": ["#event-log", "#callback-table-body"], // Event log and callback counts (dynamic)
+ quickstart: ["#server-time"], // Server time display
"say-server": [".playBtn", ".playOverlayBtn"], // Play buttons may have different states
shadertoy: ["#canvas"], // WebGL shader canvas (animated)
"system-monitor": [
@@ -101,6 +102,11 @@ const ALL_SERVERS = [
{ key: "map-server", name: "CesiumJS Map Server", dir: "map-server" },
{ key: "pdf-server", name: "PDF Server", dir: "pdf-server" },
{ key: "qr-server", name: "QR Code Server", dir: "qr-server" },
+ {
+ key: "quickstart",
+ name: "Quickstart MCP App Server",
+ dir: "quickstart",
+ },
{ key: "say-server", name: "Say Demo", dir: "say-server" },
{
key: "scenario-modeler",
diff --git a/tests/e2e/servers.spec.ts-snapshots/quickstart.png b/tests/e2e/servers.spec.ts-snapshots/quickstart.png
new file mode 100644
index 0000000..18ad40e
Binary files /dev/null and b/tests/e2e/servers.spec.ts-snapshots/quickstart.png differ