From 4b5b0c7d7903090e317d1d1750f15d6e9cce8139 Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Tue, 2 Dec 2025 18:35:43 -0600 Subject: [PATCH] Add basic-server-vanillajs example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates MCP App SDK usage with vanilla JavaScript (no framework), providing parity with the existing basic-server-react example. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .prettierignore | 2 + README.md | 3 +- examples/basic-server-react/README.md | 3 + examples/basic-server-vanillajs/README.md | 56 ++++++++++ examples/basic-server-vanillajs/mcp-app.html | 34 ++++++ examples/basic-server-vanillajs/package.json | 29 +++++ examples/basic-server-vanillajs/server.ts | 104 ++++++++++++++++++ .../basic-server-vanillajs/src/global.css | 12 ++ .../basic-server-vanillajs/src/mcp-app.css | 63 +++++++++++ .../basic-server-vanillajs/src/mcp-app.ts | 91 +++++++++++++++ examples/basic-server-vanillajs/tsconfig.json | 19 ++++ .../basic-server-vanillajs/vite.config.ts | 24 ++++ package-lock.json | 5 +- package.json | 4 +- 14 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 examples/basic-server-vanillajs/README.md create mode 100644 examples/basic-server-vanillajs/mcp-app.html create mode 100644 examples/basic-server-vanillajs/package.json create mode 100644 examples/basic-server-vanillajs/server.ts create mode 100644 examples/basic-server-vanillajs/src/global.css create mode 100644 examples/basic-server-vanillajs/src/mcp-app.css create mode 100644 examples/basic-server-vanillajs/src/mcp-app.ts create mode 100644 examples/basic-server-vanillajs/tsconfig.json create mode 100644 examples/basic-server-vanillajs/vite.config.ts diff --git a/.prettierignore b/.prettierignore index 0de301e..e2a4cdb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,5 @@ examples/basic-host/**/*.ts examples/basic-host/**/*.tsx examples/basic-server-react/**/*.ts examples/basic-server-react/**/*.tsx +examples/basic-server-vanillajs/**/*.ts +examples/basic-server-vanillajs/**/*.tsx diff --git a/README.md b/README.md index 8137cce..d9e84b3 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ Your `package.json` will then look like: ## Examples -- [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) — Example MCP server with tools that return UI Apps +- [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) — Example MCP server with tools that return UI Apps (React) +- [`examples/basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) — Example MCP server with tools that return UI Apps (vanilla JS) - [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) — Bare-bones example of hosting MCP Apps To run the examples end-to-end: diff --git a/examples/basic-server-react/README.md b/examples/basic-server-react/README.md index 087904d..120f4ce 100644 --- a/examples/basic-server-react/README.md +++ b/examples/basic-server-react/README.md @@ -2,6 +2,9 @@ An MCP App example with a React UI. +> [!TIP] +> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)! + ## Overview - Tool registration with a linked UI resource diff --git a/examples/basic-server-vanillajs/README.md b/examples/basic-server-vanillajs/README.md new file mode 100644 index 0000000..8d22726 --- /dev/null +++ b/examples/basic-server-vanillajs/README.md @@ -0,0 +1,56 @@ +# Example: Basic Server (Vanilla JS) + +An MCP App example with a vanilla JavaScript UI (no framework). + +> [!TIP] +> Looking for a React-based example? See [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react)! + +## Overview + +- Tool registration with a linked UI resource +- Vanilla JS UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class directly +- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`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), [`sendOpenLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendopenlink) + +## Key Files + +- [`server.ts`](server.ts) - MCP server with tool and resource registration +- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - Vanilla JS UI using `App` class + +## Getting Started + +```bash +npm install +npm run dev +``` + +## How It Works + +1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`). +2. When the tool is invoked, the Host renders the UI from the resource. +3. The UI uses the MCP App SDK API to communicate with the host and call server tools. + +## Vanilla JS Pattern + +```typescript +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; + +// Get element references from static HTML +const button = document.getElementById("my-button")!; + +// Create app instance +const app = new App({ name: "My App", version: "1.0.0" }); + +// Register handlers BEFORE connecting +app.ontoolresult = (result) => { + /* handle result */ +}; +app.onerror = console.error; + +// Add event listeners +button.addEventListener("click", () => { + /* ... */ +}); + +// Connect to host +app.connect(new PostMessageTransport(window.parent)); +``` diff --git a/examples/basic-server-vanillajs/mcp-app.html b/examples/basic-server-vanillajs/mcp-app.html new file mode 100644 index 0000000..1a88d60 --- /dev/null +++ b/examples/basic-server-vanillajs/mcp-app.html @@ -0,0 +1,34 @@ + + + + + + Get Time App + + +
+

Watch activity in the DevTools console!

+ +
+

Server Time: Loading...

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json new file mode 100644 index 0000000..3c1b7ff --- /dev/null +++ b/examples/basic-server-vanillajs/package.json @@ -0,0 +1,29 @@ +{ + "name": "basic-server-vanillajs", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "INPUT=mcp-app.html vite build", + "watch": "INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "NODE_ENV=development npm run build && npm run serve", + "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts new file mode 100644 index 0000000..b5c792c --- /dev/null +++ b/examples/basic-server-vanillajs/server.ts @@ -0,0 +1,104 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import cors from "cors"; +import express, { type Request, type Response } from "express"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { RESOURCE_URI_META_KEY } from "../../dist/src/app"; + +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001; +const DIST_DIR = path.join(import.meta.dirname, "dist"); + + +const server = new McpServer({ + name: "MCP App Server", + version: "1.0.0", +}); + + +// MCP Apps require two-part registration: a tool (what the LLM calls) and a +// resource (the UI it renders). The `_meta` field on the tool links to the +// resource URI, telling hosts which UI to display when the tool executes. +{ + const resourceUri = "ui://get-time/mcp-app.html"; + + server.registerTool( + "get-time", + { + title: "Get Time", + description: "Returns the current server time as an ISO 8601 string.", + inputSchema: {}, + outputSchema: { time: z.string() }, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async (): Promise => { + const time = new Date().toISOString(); + return { + content: [{ type: "text", text: time }], + structuredContent: { time }, + }; + }, + ); + + server.registerResource( + resourceUri, + resourceUri, + {}, + async (): Promise => { + const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); + + return { + contents: [ + // Per the MCP App specification, "text/html+mcp" signals to the Host + // that this resource is indeed for an MCP App UI. + { uri: resourceUri, mimeType: "text/html+mcp", text: html }, + ], + }; + }, + ); +} + + +const app = express(); +app.use(cors()); +app.use(express.json()); + +app.post("/mcp", async (req: Request, res: Response) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { transport.close(); }); + + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", 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, () => { + console.log(`Server listening on http://localhost:${PORT}/mcp`); +}); + +function shutdown() { + console.log("\nShutting down..."); + httpServer.close(() => { + console.log("Server closed"); + process.exit(0); + }); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/examples/basic-server-vanillajs/src/global.css b/examples/basic-server-vanillajs/src/global.css new file mode 100644 index 0000000..97cda44 --- /dev/null +++ b/examples/basic-server-vanillajs/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/basic-server-vanillajs/src/mcp-app.css b/examples/basic-server-vanillajs/src/mcp-app.css new file mode 100644 index 0000000..49caa84 --- /dev/null +++ b/examples/basic-server-vanillajs/src/mcp-app.css @@ -0,0 +1,63 @@ +.main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + + min-width: 425px; + + > * { + margin-top: 0; + margin-bottom: 0; + } + + > * + * { + margin-top: 1.5rem; + } +} + +.action { + > * { + margin-top: 0; + margin-bottom: 0; + width: 100%; + } + + > * + * { + margin-top: 0.5rem; + } + + /* Consistent font for form inputs (inherits from global.css) */ + textarea, + input { + font-family: inherit; + font-size: inherit; + } + + button { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + color: white; + font-weight: bold; + background-color: var(--color-primary); + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--color-primary-hover); + } + } +} + +.notice { + padding: 0.5rem 0.75rem; + color: var(--color-primary); + text-align: center; + font-style: italic; + background-color: var(--color-notice-bg); + + &::before { + content: "â„šī¸ "; + font-style: normal; + } +} diff --git a/examples/basic-server-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts new file mode 100644 index 0000000..9c9364a --- /dev/null +++ b/examples/basic-server-vanillajs/src/mcp-app.ts @@ -0,0 +1,91 @@ +/** + * @file App that demonstrates a few features using MCP Apps SDK with vanilla JS. + */ +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import "./global.css"; +import "./mcp-app.css"; + + +const log = { + info: console.log.bind(console, "[APP]"), + warn: console.warn.bind(console, "[APP]"), + error: console.error.bind(console, "[APP]"), +}; + + +function extractTime(result: CallToolResult): string { + const { time } = (result.structuredContent as { time?: string }) ?? {}; + return time ?? "[ERROR]"; +} + + +// Get element references +const serverTimeEl = document.getElementById("server-time")!; +const getTimeBtn = document.getElementById("get-time-btn")!; +const messageText = document.getElementById("message-text") as HTMLTextAreaElement; +const sendMessageBtn = document.getElementById("send-message-btn")!; +const logText = document.getElementById("log-text") as HTMLInputElement; +const sendLogBtn = document.getElementById("send-log-btn")!; +const linkUrl = document.getElementById("link-url") as HTMLInputElement; +const openLinkBtn = document.getElementById("open-link-btn")!; + + +// Create app instance +const app = new App({ name: "Get Time App", version: "1.0.0" }); + + +// Register handlers BEFORE connecting +app.ontoolinput = (params) => { + log.info("Received tool call input:", params); +}; + +app.ontoolresult = (result) => { + log.info("Received tool call result:", result); + serverTimeEl.textContent = extractTime(result); +}; + +app.onerror = log.error; + + +// Add event listeners +getTimeBtn.addEventListener("click", async () => { + try { + log.info("Calling get-time tool..."); + const result = await app.callServerTool({ name: "get-time", arguments: {} }); + log.info("get-time result:", result); + serverTimeEl.textContent = extractTime(result); + } catch (e) { + log.error(e); + serverTimeEl.textContent = "[ERROR]"; + } +}); + +sendMessageBtn.addEventListener("click", async () => { + const signal = AbortSignal.timeout(5000); + try { + log.info("Sending message text to Host:", messageText.value); + const { isError } = await app.sendMessage( + { role: "user", content: [{ type: "text", text: messageText.value }] }, + { signal }, + ); + log.info("Message", isError ? "rejected" : "accepted"); + } catch (e) { + log.error("Message send error:", signal.aborted ? "timed out" : e); + } +}); + +sendLogBtn.addEventListener("click", async () => { + log.info("Sending log text to Host:", logText.value); + await app.sendLog({ level: "info", data: logText.value }); +}); + +openLinkBtn.addEventListener("click", async () => { + log.info("Sending open link request to Host:", linkUrl.value); + const { isError } = await app.sendOpenLink({ url: linkUrl.value }); + log.info("Open link request", isError ? "rejected" : "accepted"); +}); + + +// Connect to host +app.connect(new PostMessageTransport(window.parent)); diff --git a/examples/basic-server-vanillajs/tsconfig.json b/examples/basic-server-vanillajs/tsconfig.json new file mode 100644 index 0000000..535267b --- /dev/null +++ b/examples/basic-server-vanillajs/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"] +} diff --git a/examples/basic-server-vanillajs/vite.config.ts b/examples/basic-server-vanillajs/vite.config.ts new file mode 100644 index 0000000..6ff6d99 --- /dev/null +++ b/examples/basic-server-vanillajs/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 0af4d12..ae46820 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,7 +92,6 @@ }, "examples/basic-server-vanillajs": { "version": "1.0.0", - "extraneous": true, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", "@modelcontextprotocol/sdk": "^1.22.0", @@ -1116,6 +1115,10 @@ "resolved": "examples/basic-server-react", "link": true }, + "node_modules/basic-server-vanillajs": { + "resolved": "examples/basic-server-vanillajs", + "link": true + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/package.json b/package.json index aba6945..efb6381 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,16 @@ "build": "bun build.bun.ts", "build:all": "npm run build && npm run examples:build", "test": "bun test", - "examples:build": "concurrently --kill-others-on-fail 'npm run --workspace=examples/basic-host build' 'npm run --workspace=examples/basic-server-react build'", + "examples:build": "concurrently --kill-others-on-fail 'npm run --workspace=examples/basic-host build' 'npm run --workspace=examples/basic-server-react build' 'npm run --workspace=examples/basic-server-vanillajs build'", "examples:start": "NODE_ENV=development npm run build && concurrently 'npm run examples:start:basic-host' 'npm run examples:start:basic-server-react'", "examples:start:basic-host": "npm run --workspace=examples/basic-host start", "examples:start:basic-server-react": "npm run --workspace=examples/basic-server-react start", + "examples:start:basic-server-vanillajs": "npm run --workspace=examples/basic-server-vanillajs start", "watch": "nodemon --watch src --ext ts,tsx --exec 'bun build.bun.ts'", "examples:dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run examples:dev:basic-host' 'npm run examples:dev:basic-server-react'", "examples:dev:basic-host": "npm run --workspace=examples/basic-host dev", "examples:dev:basic-server-react": "npm run --workspace=examples/basic-server-react dev", + "examples:dev:basic-server-vanillajs": "npm run --workspace=examples/basic-server-vanillajs dev", "prepare": "npm run build && husky", "docs": "typedoc", "docs:watch": "typedoc --watch",