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! +![Your first MCP App](./quickstart-success.png) + +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