Rewrite Quickstart guide with type-checked code examples

Rewrites the Quickstart tutorial to provide a more focused, step-by-step
introduction to building MCP Apps. The guide now uses synced code fences
that pull from `examples/quickstart/`, ensuring all code shown in the
documentation is type-checked and tested.

Key improvements:
- Clearer project setup instructions with explicit commands
- Streamlined explanations of the tool + resource registration pattern
- Added screenshot showing the completed app
- E2E test coverage validates the example works end-to-end

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2026-01-23 18:03:33 -06:00
commit 2109dccadb
No known key found for this signature in database
GPG key ID: 0DA40AFD21043691
23 changed files with 672 additions and 136 deletions

View file

@ -2,5 +2,6 @@ examples/basic-host/**/*.ts
examples/basic-host/**/*.tsx examples/basic-host/**/*.tsx
examples/basic-server-*/**/*.ts examples/basic-server-*/**/*.ts
examples/basic-server-*/**/*.tsx examples/basic-server-*/**/*.tsx
examples/quickstart/**/*.ts
**/vendor/** **/vendor/**
SKILL.md SKILL.md

BIN
docs/quickstart-success.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -4,200 +4,351 @@ title: Quickstart
# Build Your First MCP App # 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 ## 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] > [!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 ## 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) 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.
- Familiarity with the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- Node.js 18+
> [!TIP] We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) to build the server.
> 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.
## 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 ```bash
mkdir my-mcp-app && cd my-mcp-app mkdir my-mcp-app && cd my-mcp-app
npm init -y
``` ```
Install dependencies: Install the dependencies you'll need:
```bash ```bash
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk npm init -y
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx 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'"
```
<details>
<summary>Create <a href="https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/tsconfig.json"><code>tsconfig.json</code></a>:</summary>
<!-- prettier-ignore -->
```json source="../examples/quickstart/tsconfig.json"
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true, "strict": true,
"esModuleInterop": true,
"skipLibCheck": 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: </details>
```typescript <details>
<summary>Create <a href="https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/tsconfig.server.json"><code>tsconfig.server.json</code></a> — for compiling server-side code:</summary>
<!-- prettier-ignore -->
```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"]
}
```
</details>
<details>
<summary>Create <a href="https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/vite.config.ts"><code>vite.config.ts</code></a> — bundles UI into a single HTML file:</summary>
<!-- prettier-ignore -->
```ts source="../examples/quickstart/vite.config.ts"
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile"; 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({ export default defineConfig({
plugins: [viteSingleFile()], plugins: [viteSingleFile()],
build: { build: {
outDir: "dist", sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: { rollupOptions: {
input: process.env.INPUT, input: INPUT,
}, },
outDir: "dist",
emptyOutDir: false,
}, },
}); });
``` ```
Add to your `package.json`: </details>
```json Your `my-mcp-app` directory should now contain:
{
"type": "module", ```
"scripts": { my-mcp-app/
"build": "INPUT=mcp-app.html vite build", ├── package.json
"serve": "npx tsx server.ts" ├── tsconfig.json
} ├── tsconfig.server.json
} └── vite.config.ts
``` ```
> [!NOTE] With the project scaffolded, let's write the server code.
> **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)
## 2. Create the Server ## 2. Register the tool and UI resource
MCP Apps use a **two-part registration**: MCP Apps use a **two-part registration**:
1. A **tool** that the LLM/host calls 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 <!-- prettier-ignore -->
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; ```ts source="../examples/quickstart/server.ts"
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { import {
registerAppTool,
registerAppResource, registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE, RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"; } from "@modelcontextprotocol/ext-apps/server";
import cors from "cors"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import express from "express";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
const server = new McpServer({ const DIST_DIR = path.join(import.meta.dirname, "dist");
name: "My 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"; * Creates a new MCP server instance with tools and resources registered.
*/
// Register a tool with UI metadata. When the host calls this tool, it reads export function createServer(): McpServer {
// `_meta.ui.resourceUri` to know which resource to fetch and render as an const server = new McpServer({
// interactive UI. name: "Quickstart MCP App Server",
registerAppTool( version: "1.0.0",
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,
}); });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
expressApp.listen(3001, (err) => { // Two-part registration: tool + resource, tied together by the resource URI.
if (err) { const resourceUri = "ui://get-time/mcp-app.html";
console.error("Error starting server:", err);
process.exit(1); // 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;
}
```
<details>
<summary>Create <a href="https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/main.ts"><code>main.ts</code></a> — the entry point that starts the server:</summary>
<!-- prettier-ignore -->
```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<void> {
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<void> {
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] </details>
> **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 ```bash
npx tsc --noEmit 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:
<!-- prettier-ignore -->
```html source="../examples/quickstart/mcp-app.html"
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -214,9 +365,10 @@ Create `mcp-app.html`:
</html> </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 <!-- prettier-ignore -->
```ts source="../examples/quickstart/src/mcp-app.ts"
import { App } from "@modelcontextprotocol/ext-apps"; import { App } from "@modelcontextprotocol/ext-apps";
// Get element references // Get element references
@ -226,7 +378,8 @@ const getTimeBtn = document.getElementById("get-time-btn")!;
// Create app instance // Create app instance
const app = new App({ name: "Get Time App", version: "1.0.0" }); 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) => { app.ontoolresult = (result) => {
const time = result.content?.find((c) => c.type === "text")?.text; const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]"; serverTimeEl.textContent = time ?? "[ERROR]";
@ -234,6 +387,7 @@ app.ontoolresult = (result) => {
// Wire up button click // Wire up button click
getTimeBtn.addEventListener("click", async () => { 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 result = await app.callServerTool({ name: "get-time", arguments: {} });
const time = result.content?.find((c) => c.type === "text")?.text; const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]"; serverTimeEl.textContent = time ?? "[ERROR]";
@ -243,30 +397,50 @@ getTimeBtn.addEventListener("click", async () => {
app.connect(); app.connect();
``` ```
> [!NOTE] Your `my-mcp-app` directory should now contain:
> **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)
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 ```bash
npm run build npm run build
``` ```
This produces `dist/mcp-app.html` which contains your bundled app: This produces `dist/mcp-app.html`:
```console ```console
$ ls dist/mcp-app.html $ ls dist/
dist/mcp-app.html 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 ```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)): **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 git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps/examples/basic-host cd ext-apps/examples/basic-host
npm install npm install
npm run start npm start
``` ```
Open http://localhost:8080 in your browser: Open http://localhost:8080 in your browser:
1. Select **get-time** from the "Tool Name" dropdown 1. Select **get-time** from the "Tool Name" dropdown
2. Click **Call Tool** 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! 4. Click **Get Server Time** — the current time appears!
![Your first MCP App](./quickstart-success.png)
You've built your first MCP App!
## Next Steps ## 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 - **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/) - **API reference**: See the full [API documentation](https://modelcontextprotocol.github.io/ext-apps/api/)

View file

@ -145,11 +145,11 @@
} }
.appIframePanel { .appIframePanel {
min-height: 200px; min-height: 100px;
iframe { iframe {
width: 100%; width: 100%;
height: 600px; height: 400px;
box-sizing: border-box; box-sizing: border-box;
border: 3px dashed var(--color-border); border: 3px dashed var(--color-border);
border-radius: 4px; border-radius: 4px;

View file

@ -29,7 +29,7 @@ export function createServer(): McpServer {
title: "Get Time", title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.", description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {}, inputSchema: {},
_meta: { ui: { resourceUri } }, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
const time = new Date().toISOString(); const time = new Date().toISOString();

View file

@ -30,7 +30,7 @@ export function createServer(): McpServer {
title: "Get Time", title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.", description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {}, inputSchema: {},
_meta: { ui: { resourceUri } }, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
const time = new Date().toISOString(); const time = new Date().toISOString();

View file

@ -29,7 +29,7 @@ export function createServer(): McpServer {
title: "Get Time", title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.", description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {}, inputSchema: {},
_meta: { ui: { resourceUri } }, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
const time = new Date().toISOString(); const time = new Date().toISOString();

View file

@ -29,7 +29,7 @@ export function createServer(): McpServer {
title: "Get Time", title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.", description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {}, inputSchema: {},
_meta: { ui: { resourceUri } }, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
const time = new Date().toISOString(); const time = new Date().toISOString();

View file

@ -33,7 +33,7 @@ export function createServer(): McpServer {
outputSchema: z.object({ outputSchema: z.object({
time: z.string(), time: z.string(),
}), }),
_meta: { ui: { resourceUri } }, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
const time = new Date().toISOString(); const time = new Date().toISOString();

View file

@ -29,7 +29,7 @@ export function createServer(): McpServer {
title: "Get Time", title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.", description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {}, inputSchema: {},
_meta: { ui: { resourceUri } }, _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
const time = new Date().toISOString(); const time = new Date().toISOString();

2
examples/quickstart/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

View file

@ -0,0 +1,3 @@
# Quickstart Server
This is the example code for the [Quickstart guide](../../docs/quickstart.md).

View file

@ -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<void> {
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<void> {
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);
});

View file

@ -0,0 +1,14 @@
<!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>

View file

@ -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"
}
}

View file

@ -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;
}

View file

@ -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();

View file

@ -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"]
}

View file

@ -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"]
}

View file

@ -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,
},
});

Some files were not shown because too many files have changed in this diff Show more