import { createServer } from "node:http";
import { Readable } from "node:stream";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
const VIEW_URI = "ui://weather/card.html";
const TEMPS: Record<string, number> = { london: 14, tokyo: 22, austin: 31 };
// The View: one HTML document, no build step. It speaks the MCP Apps bridge
// (JSON-RPC over postMessage) directly, so it has no dependencies.
const VIEW_HTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin: 0; padding: 16px; font-family: system-ui, sans-serif;
background: var(--color-background-primary, #fff);
color: var(--color-text-primary, #1f1e1d); }
.card { border: 1px solid var(--color-border-secondary, #ddd); border-radius: 12px; padding: 16px; }
.temp { font-size: 40px; font-weight: 600; }
.muted { color: var(--color-text-secondary, #666); }
</style>
</head>
<body>
<div class="card">
<div id="city" class="muted">Waiting for data…</div>
<div id="temp" class="temp"></div>
</div>
<script>
let nextId = 1;
const pending = new Map();
function send(msg) { window.parent.postMessage({ jsonrpc: "2.0", ...msg }, "*"); }
function request(method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
send({ id, method, params });
});
}
function notify(method, params) { send({ method, params }); }
function applyTheme(ctx) {
const vars = (ctx && ctx.styles && ctx.styles.variables) || {};
for (const [k, v] of Object.entries(vars)) if (v) document.documentElement.style.setProperty(k, v);
}
function render(result) {
const data = result && result.structuredContent;
if (!data) return;
document.getElementById("city").textContent = data.city;
document.getElementById("temp").textContent = data.temperatureC + " °C";
}
function reportSize() {
notify("ui/notifications/size-changed", { height: document.documentElement.scrollHeight });
}
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
const msg = event.data;
if (!msg || msg.jsonrpc !== "2.0") return;
if (msg.id !== undefined && (msg.result !== undefined || msg.error)) {
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
} else if (msg.method === "ui/notifications/tool-result") {
render(msg.params); reportSize();
} else if (msg.method === "ui/notifications/host-context-changed") {
applyTheme(msg.params);
} else if (msg.method === "ui/resource-teardown" && msg.id !== undefined) {
send({ id: msg.id, result: {} });
}
});
// 2026-01-26 is the MCP Apps (ext-apps) bridge revision, separate from the
// MCP protocol revision (2026-07-28) the server speaks.
request("ui/initialize", {
protocolVersion: "2026-01-26",
appInfo: { name: "weather-card", version: "1.0.0" },
appCapabilities: {},
}).then((result) => {
applyTheme(result.hostContext);
notify("ui/notifications/initialized", {});
reportSize();
});
</script>
</body>
</html>`;
function buildServer(): McpServer {
const server = new McpServer(
{ name: "weather-demo", version: "1.0.0" },
{
instructions:
"Look up the current temperature for a city. Use get_temperature to read data; " +
"use show_weather only when the user wants a weather card displayed.",
},
);
// Data tool: no UI, safe to call repeatedly.
server.registerTool(
"get_temperature",
{
title: "Get temperature",
description: "Get the current temperature in Celsius for london, tokyo or austin.",
inputSchema: z.object({ city: z.string().describe("City name, e.g. london") }),
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
},
async ({ city }) => {
const t = TEMPS[city.toLowerCase()];
if (t === undefined) {
return { isError: true, content: [{ type: "text", text: `Unknown city: ${city}` }] };
}
return { content: [{ type: "text", text: `${city}: ${t} °C` }] };
},
);
// Render tool: its result is delivered to the View.
registerAppTool(
server,
"show_weather",
{
title: "Show weather card",
description: "Display a weather card for a city. Call only when the user wants to see it.",
inputSchema: z.object({ city: z.string() }),
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
_meta: { ui: { resourceUri: VIEW_URI } },
},
async ({ city }) => {
const t = TEMPS[city.toLowerCase()];
if (t === undefined) {
return { isError: true, content: [{ type: "text", text: `Unknown city: ${city}` }] };
}
return {
content: [{ type: "text", text: `Showing weather for ${city}: ${t} °C` }],
structuredContent: { city, temperatureC: t },
};
},
);
// The ui:// resource that holds the View.
registerAppResource(
server,
"Weather card",
VIEW_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async () => ({
contents: [
{
uri: VIEW_URI,
mimeType: RESOURCE_MIME_TYPE,
text: VIEW_HTML,
// No network access needed, so declare no domains.
_meta: { ui: { csp: { connectDomains: [], resourceDomains: [] }, prefersBorder: true } },
},
],
}),
);
return server;
}
// Streamable HTTP, stateless and dual-era: a fresh server per request.
// 2026-07-28 clients (server/discover) and legacy initialize clients are both served.
const mcp = createMcpHandler(() => buildServer());
const http = createServer(async (req, res) => {
if (req.url !== "/mcp") {
res.writeHead(404).end();
return;
}
// createMcpHandler is web-standard (Request -> Response); adapt node:http.
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const request = new Request(`http://${req.headers.host}${req.url}`, {
method: req.method,
headers: req.headers as Record<string, string>,
...(hasBody ? { body: Readable.toWeb(req) as ReadableStream, duplex: "half" } : {}),
} as RequestInit);
const response = await mcp.fetch(request);
res.writeHead(response.status, Object.fromEntries(response.headers));
if (response.body) for await (const chunk of response.body) res.write(chunk);
res.end();
});
const port = Number(process.env.PORT ?? 3000);
http.listen(port, () => console.log(`MCP server listening on http://localhost:${port}/mcp`));