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
10 changed files with 672 additions and 136 deletions

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