Skip to content

Commit 700f27a

Browse files
Add basic-server-vanillajs example
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 <noreply@anthropic.com>
1 parent 925904c commit 700f27a

12 files changed

Lines changed: 867 additions & 1 deletion

File tree

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ examples/basic-host/**/*.ts
22
examples/basic-host/**/*.tsx
33
examples/basic-server-react/**/*.ts
44
examples/basic-server-react/**/*.tsx
5+
examples/basic-server-vanillajs/**/*.ts
6+
examples/basic-server-vanillajs/**/*.tsx
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Example: Basic Server (Vanilla JS)
2+
3+
An MCP App example with a vanilla JavaScript UI (no framework).
4+
5+
## Overview
6+
7+
- Tool registration with a linked UI resource
8+
- Vanilla JS UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class directly
9+
- 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)
10+
11+
## Key Files
12+
13+
- [`server.ts`](server.ts) - MCP server with tool and resource registration
14+
- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - Vanilla JS UI using `App` class
15+
16+
## Getting Started
17+
18+
```bash
19+
npm install
20+
npm run dev
21+
```
22+
23+
## How It Works
24+
25+
1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`).
26+
2. When the tool is invoked, the Host renders the UI from the resource.
27+
3. The UI uses the MCP App SDK API to communicate with the host and call server tools.
28+
29+
## Vanilla JS Pattern
30+
31+
```typescript
32+
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
33+
34+
// Get element references from static HTML
35+
const button = document.getElementById("my-button")!;
36+
37+
// Create app instance
38+
const app = new App({ name: "My App", version: "1.0.0" });
39+
40+
// Register handlers BEFORE connecting
41+
app.ontoolresult = (result) => {
42+
/* handle result */
43+
};
44+
app.onerror = console.error;
45+
46+
// Add event listeners
47+
button.addEventListener("click", () => {
48+
/* ... */
49+
});
50+
51+
// Connect to host
52+
app.connect(new PostMessageTransport(window.parent));
53+
```
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Get Time App</title>
7+
</head>
8+
<body>
9+
<main class="main">
10+
<p class="notice">Watch activity in the DevTools console!</p>
11+
12+
<div class="action">
13+
<p><strong>Server Time:</strong> <code id="server-time">Loading...</code></p>
14+
<button id="get-time-btn">Get Server Time</button>
15+
</div>
16+
17+
<div class="action">
18+
<textarea id="message-text">This is message text.</textarea>
19+
<button id="send-message-btn">Send Message</button>
20+
</div>
21+
22+
<div class="action">
23+
<input type="text" id="log-text" value="This is log text.">
24+
<button id="send-log-btn">Send Log</button>
25+
</div>
26+
27+
<div class="action">
28+
<input type="url" id="link-url" value="https://modelcontextprotocol.io/">
29+
<button id="open-link-btn">Open Link</button>
30+
</div>
31+
</main>
32+
<script type="module" src="/src/mcp-app.ts"></script>
33+
</body>
34+
</html>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "basic-server-vanillajs",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "INPUT=mcp-app.html vite build",
8+
"watch": "INPUT=mcp-app.html vite build --watch",
9+
"serve": "bun server.ts",
10+
"start": "NODE_ENV=development npm run build && npm run serve",
11+
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
12+
},
13+
"dependencies": {
14+
"@modelcontextprotocol/ext-apps": "../..",
15+
"@modelcontextprotocol/sdk": "^1.22.0",
16+
"zod": "^3.25.0"
17+
},
18+
"devDependencies": {
19+
"@types/cors": "^2.8.19",
20+
"@types/express": "^5.0.0",
21+
"@types/node": "^22.0.0",
22+
"concurrently": "^9.2.1",
23+
"cors": "^2.8.5",
24+
"express": "^5.1.0",
25+
"typescript": "^5.9.3",
26+
"vite": "^6.0.0",
27+
"vite-plugin-singlefile": "^2.3.0"
28+
}
29+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3+
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
4+
import cors from "cors";
5+
import express, { type Request, type Response } from "express";
6+
import fs from "node:fs/promises";
7+
import path from "node:path";
8+
import { z } from "zod";
9+
import { RESOURCE_URI_META_KEY } from "../../dist/src/app";
10+
11+
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
12+
const DIST_DIR = path.join(import.meta.dirname, "dist");
13+
14+
15+
const server = new McpServer({
16+
name: "MCP App Server",
17+
version: "1.0.0",
18+
});
19+
20+
21+
// MCP Apps require two-part registration: a tool (what the LLM calls) and a
22+
// resource (the UI it renders). The `_meta` field on the tool links to the
23+
// resource URI, telling hosts which UI to display when the tool executes.
24+
{
25+
const resourceUri = "ui://get-time/mcp-app.html";
26+
27+
server.registerTool(
28+
"get-time",
29+
{
30+
title: "Get Time",
31+
description: "Returns the current server time as an ISO 8601 string.",
32+
inputSchema: {},
33+
outputSchema: { time: z.string() },
34+
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
35+
},
36+
async (): Promise<CallToolResult> => {
37+
const time = new Date().toISOString();
38+
return {
39+
content: [{ type: "text", text: time }],
40+
structuredContent: { time },
41+
};
42+
},
43+
);
44+
45+
server.registerResource(
46+
resourceUri,
47+
resourceUri,
48+
{},
49+
async (): Promise<ReadResourceResult> => {
50+
const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
51+
52+
return {
53+
contents: [
54+
// Per the MCP App specification, "text/html+mcp" signals to the Host
55+
// that this resource is indeed for an MCP App UI.
56+
{ uri: resourceUri, mimeType: "text/html+mcp", text: html },
57+
],
58+
};
59+
},
60+
);
61+
}
62+
63+
64+
const app = express();
65+
app.use(cors());
66+
app.use(express.json());
67+
68+
app.post("/mcp", async (req: Request, res: Response) => {
69+
try {
70+
const transport = new StreamableHTTPServerTransport({
71+
sessionIdGenerator: undefined,
72+
enableJsonResponse: true,
73+
});
74+
res.on("close", () => { transport.close(); });
75+
76+
await server.connect(transport);
77+
78+
await transport.handleRequest(req, res, req.body);
79+
} catch (error) {
80+
console.error("Error handling MCP request:", error);
81+
if (!res.headersSent) {
82+
res.status(500).json({
83+
jsonrpc: "2.0",
84+
error: { code: -32603, message: "Internal server error" },
85+
id: null,
86+
});
87+
}
88+
}
89+
});
90+
91+
const httpServer = app.listen(PORT, () => {
92+
console.log(`Server listening on http://localhost:${PORT}/mcp`);
93+
});
94+
95+
function shutdown() {
96+
console.log("\nShutting down...");
97+
httpServer.close(() => {
98+
console.log("Server closed");
99+
process.exit(0);
100+
});
101+
}
102+
103+
process.on("SIGINT", shutdown);
104+
process.on("SIGTERM", shutdown);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
* {
2+
box-sizing: border-box;
3+
}
4+
5+
html, body {
6+
font-family: system-ui, -apple-system, sans-serif;
7+
font-size: 1rem;
8+
}
9+
10+
code {
11+
font-size: 1em;
12+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
.main {
2+
--color-primary: #2563eb;
3+
--color-primary-hover: #1d4ed8;
4+
--color-notice-bg: #eff6ff;
5+
6+
min-width: 425px;
7+
8+
> * {
9+
margin-top: 0;
10+
margin-bottom: 0;
11+
}
12+
13+
> * + * {
14+
margin-top: 1.5rem;
15+
}
16+
}
17+
18+
.action {
19+
> * {
20+
margin-top: 0;
21+
margin-bottom: 0;
22+
width: 100%;
23+
}
24+
25+
> * + * {
26+
margin-top: 0.5rem;
27+
}
28+
29+
/* Consistent font for form inputs (inherits from global.css) */
30+
textarea,
31+
input {
32+
font-family: inherit;
33+
font-size: inherit;
34+
}
35+
36+
button {
37+
padding: 0.5rem 1rem;
38+
border: none;
39+
border-radius: 6px;
40+
color: white;
41+
font-weight: bold;
42+
background-color: var(--color-primary);
43+
cursor: pointer;
44+
45+
&:hover,
46+
&:focus-visible {
47+
background-color: var(--color-primary-hover);
48+
}
49+
}
50+
}
51+
52+
.notice {
53+
padding: 0.5rem 0.75rem;
54+
color: var(--color-primary);
55+
text-align: center;
56+
font-style: italic;
57+
background-color: var(--color-notice-bg);
58+
59+
&::before {
60+
content: "ℹ️ ";
61+
font-style: normal;
62+
}
63+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @file App that demonstrates a few features using MCP Apps SDK with vanilla JS.
3+
*/
4+
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
5+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6+
import "./global.css";
7+
import "./mcp-app.css";
8+
9+
10+
const log = {
11+
info: console.log.bind(console, "[APP]"),
12+
warn: console.warn.bind(console, "[APP]"),
13+
error: console.error.bind(console, "[APP]"),
14+
};
15+
16+
17+
function extractTime(result: CallToolResult): string {
18+
const { time } = (result.structuredContent as { time?: string }) ?? {};
19+
return time ?? "[ERROR]";
20+
}
21+
22+
23+
// Get element references
24+
const serverTimeEl = document.getElementById("server-time")!;
25+
const getTimeBtn = document.getElementById("get-time-btn")!;
26+
const messageText = document.getElementById("message-text") as HTMLTextAreaElement;
27+
const sendMessageBtn = document.getElementById("send-message-btn")!;
28+
const logText = document.getElementById("log-text") as HTMLInputElement;
29+
const sendLogBtn = document.getElementById("send-log-btn")!;
30+
const linkUrl = document.getElementById("link-url") as HTMLInputElement;
31+
const openLinkBtn = document.getElementById("open-link-btn")!;
32+
33+
34+
// Create app instance
35+
const app = new App({ name: "Get Time App", version: "1.0.0" });
36+
37+
38+
// Register handlers BEFORE connecting
39+
app.ontoolinput = (params) => {
40+
log.info("Received tool call input:", params);
41+
};
42+
43+
app.ontoolresult = (result) => {
44+
log.info("Received tool call result:", result);
45+
serverTimeEl.textContent = extractTime(result);
46+
};
47+
48+
app.onerror = log.error;
49+
50+
51+
// Add event listeners
52+
getTimeBtn.addEventListener("click", async () => {
53+
try {
54+
log.info("Calling get-time tool...");
55+
const result = await app.callServerTool({ name: "get-time", arguments: {} });
56+
log.info("get-time result:", result);
57+
serverTimeEl.textContent = extractTime(result);
58+
} catch (e) {
59+
log.error(e);
60+
serverTimeEl.textContent = "[ERROR]";
61+
}
62+
});
63+
64+
sendMessageBtn.addEventListener("click", async () => {
65+
const signal = AbortSignal.timeout(5000);
66+
try {
67+
log.info("Sending message text to Host:", messageText.value);
68+
const { isError } = await app.sendMessage(
69+
{ role: "user", content: [{ type: "text", text: messageText.value }] },
70+
{ signal },
71+
);
72+
log.info("Message", isError ? "rejected" : "accepted");
73+
} catch (e) {
74+
log.error("Message send error:", signal.aborted ? "timed out" : e);
75+
}
76+
});
77+
78+
sendLogBtn.addEventListener("click", async () => {
79+
log.info("Sending log text to Host:", logText.value);
80+
await app.sendLog({ level: "info", data: logText.value });
81+
});
82+
83+
openLinkBtn.addEventListener("click", async () => {
84+
log.info("Sending open link request to Host:", linkUrl.value);
85+
const { isError } = await app.sendOpenLink({ url: linkUrl.value });
86+
log.info("Open link request", isError ? "rejected" : "accepted");
87+
});
88+
89+
90+
// Connect to host
91+
app.connect(new PostMessageTransport(window.parent));

0 commit comments

Comments
 (0)