diff --git a/examples/budget-allocator-server/README.md b/examples/budget-allocator-server/README.md
new file mode 100644
index 0000000..17edb27
--- /dev/null
+++ b/examples/budget-allocator-server/README.md
@@ -0,0 +1,49 @@
+# Example: Budget Allocator App
+
+An interactive budget allocation tool demonstrating real-time data visualization with MCP Apps.
+
+## Features
+
+- **Interactive Sliders**: Adjust budget allocation across 5 categories (Marketing, Engineering, Operations, Sales, R&D)
+- **Donut Chart**: Real-time visualization of allocation distribution using Chart.js
+- **Sparkline Trends**: 24-month historical allocation data per category
+- **Percentile Badges**: Compare your allocation vs. industry benchmarks
+- **Stage Selector**: Switch between Seed, Series A, Series B, and Growth benchmarks
+- **Budget Presets**: Quick selection of $50K, $100K, $250K, or $500K totals
+
+## Running
+
+1. Install dependencies:
+
+ ```bash
+ npm install
+ ```
+
+2. Build and start the server:
+
+ ```bash
+ npm start
+ ```
+
+ The server will listen on `http://localhost:3001/mcp`.
+
+3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
+
+## Architecture
+
+### Server (`server.ts`)
+
+Exposes a single `get-budget-data` tool that returns:
+
+- Category definitions with colors and default allocations
+- Historical data (~120 data points) - 24 months of allocation history per category
+- Industry benchmarks (~60 data points) - Aggregated percentile data by company stage
+
+The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
+
+### App (`src/mcp-app.ts`)
+
+- Uses Chart.js for the donut chart visualization
+- Renders sparkline trends using inline SVG
+- Computes percentile rankings client-side from benchmark data
+- Updates all UI elements reactively on slider changes
diff --git a/examples/budget-allocator-server/mcp-app.html b/examples/budget-allocator-server/mcp-app.html
new file mode 100644
index 0000000..e57c137
--- /dev/null
+++ b/examples/budget-allocator-server/mcp-app.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ Budget Allocator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/budget-allocator-server/package.json b/examples/budget-allocator-server/package.json
new file mode 100644
index 0000000..5493b52
--- /dev/null
+++ b/examples/budget-allocator-server/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "budget-allocator-server",
+ "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",
+ "chart.js": "^4.4.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/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts
new file mode 100644
index 0000000..a4a8dd9
--- /dev/null
+++ b/examples/budget-allocator-server/server.ts
@@ -0,0 +1,365 @@
+/**
+ * Budget Allocator MCP Server
+ *
+ * Provides budget configuration, 24 months of historical allocation data,
+ * and industry benchmarks by company stage.
+ */
+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");
+
+// ---------------------------------------------------------------------------
+// Schemas - types are derived from these using z.infer
+// ---------------------------------------------------------------------------
+
+const BudgetCategorySchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ color: z.string(),
+ defaultPercent: z.number(),
+});
+
+const HistoricalMonthSchema = z.object({
+ month: z.string(),
+ allocations: z.record(z.string(), z.number()),
+});
+
+const BenchmarkPercentilesSchema = z.object({
+ p25: z.number(),
+ p50: z.number(),
+ p75: z.number(),
+});
+
+const StageBenchmarkSchema = z.object({
+ stage: z.string(),
+ categoryBenchmarks: z.record(z.string(), BenchmarkPercentilesSchema),
+});
+
+const BudgetConfigSchema = z.object({
+ categories: z.array(BudgetCategorySchema),
+ presetBudgets: z.array(z.number()),
+ defaultBudget: z.number(),
+ currency: z.string(),
+ currencySymbol: z.string(),
+});
+
+const BudgetAnalyticsSchema = z.object({
+ history: z.array(HistoricalMonthSchema),
+ benchmarks: z.array(StageBenchmarkSchema),
+ stages: z.array(z.string()),
+ defaultStage: z.string(),
+});
+
+const BudgetDataResponseSchema = z.object({
+ config: BudgetConfigSchema,
+ analytics: BudgetAnalyticsSchema,
+});
+
+// Types derived from schemas
+type BudgetDataResponse = z.infer;
+type HistoricalMonth = z.infer;
+type StageBenchmark = z.infer;
+
+// Internal type (not part of API schema - includes trendPerMonth for data generation)
+type BudgetCategoryInternal = z.infer & {
+ trendPerMonth: number;
+};
+
+// ---------------------------------------------------------------------------
+// Budget Categories with Trend Data
+// ---------------------------------------------------------------------------
+
+const CATEGORIES: BudgetCategoryInternal[] = [
+ {
+ id: "marketing",
+ name: "Marketing",
+ color: "#3b82f6",
+ defaultPercent: 25,
+ trendPerMonth: 0.15,
+ },
+ {
+ id: "engineering",
+ name: "Engineering",
+ color: "#10b981",
+ defaultPercent: 35,
+ trendPerMonth: -0.1,
+ },
+ {
+ id: "operations",
+ name: "Operations",
+ color: "#f59e0b",
+ defaultPercent: 15,
+ trendPerMonth: 0.05,
+ },
+ {
+ id: "sales",
+ name: "Sales",
+ color: "#ef4444",
+ defaultPercent: 15,
+ trendPerMonth: 0.08,
+ },
+ {
+ id: "rd",
+ name: "R&D",
+ color: "#8b5cf6",
+ defaultPercent: 10,
+ trendPerMonth: -0.18,
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Industry Benchmarks by Company Stage
+// ---------------------------------------------------------------------------
+
+const BENCHMARKS: StageBenchmark[] = [
+ {
+ stage: "Seed",
+ categoryBenchmarks: {
+ marketing: { p25: 15, p50: 20, p75: 25 },
+ engineering: { p25: 40, p50: 47, p75: 55 },
+ operations: { p25: 8, p50: 12, p75: 15 },
+ sales: { p25: 10, p50: 15, p75: 20 },
+ rd: { p25: 5, p50: 10, p75: 15 },
+ },
+ },
+ {
+ stage: "Series A",
+ categoryBenchmarks: {
+ marketing: { p25: 20, p50: 25, p75: 30 },
+ engineering: { p25: 35, p50: 40, p75: 45 },
+ operations: { p25: 10, p50: 14, p75: 18 },
+ sales: { p25: 15, p50: 20, p75: 25 },
+ rd: { p25: 8, p50: 12, p75: 15 },
+ },
+ },
+ {
+ stage: "Series B",
+ categoryBenchmarks: {
+ marketing: { p25: 22, p50: 27, p75: 32 },
+ engineering: { p25: 30, p50: 35, p75: 40 },
+ operations: { p25: 12, p50: 16, p75: 20 },
+ sales: { p25: 18, p50: 23, p75: 28 },
+ rd: { p25: 8, p50: 12, p75: 15 },
+ },
+ },
+ {
+ stage: "Growth",
+ categoryBenchmarks: {
+ marketing: { p25: 25, p50: 30, p75: 35 },
+ engineering: { p25: 25, p50: 30, p75: 35 },
+ operations: { p25: 15, p50: 18, p75: 22 },
+ sales: { p25: 20, p50: 25, p75: 30 },
+ rd: { p25: 5, p50: 8, p75: 12 },
+ },
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Historical Data Generation
+// ---------------------------------------------------------------------------
+
+/**
+ * Seeded random number generator for reproducible historical data
+ */
+function seededRandom(seed: number): () => number {
+ return () => {
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
+ return seed / 0x7fffffff;
+ };
+}
+
+/**
+ * Generate 24 months of historical allocation data with realistic trends
+ */
+function generateHistory(
+ categories: BudgetCategoryInternal[],
+): HistoricalMonth[] {
+ const months: HistoricalMonth[] = [];
+ const now = new Date();
+ const random = seededRandom(42); // Fixed seed for reproducibility
+
+ for (let i = 23; i >= 0; i--) {
+ const date = new Date(now);
+ date.setMonth(date.getMonth() - i);
+ const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
+
+ const rawAllocations: Record = {};
+
+ for (const cat of categories) {
+ // Start from default, apply trend over time, add noise
+ const monthsFromStart = 23 - i;
+ const trend = monthsFromStart * cat.trendPerMonth;
+ const noise = (random() - 0.5) * 3; // +/- 1.5%
+ rawAllocations[cat.id] = Math.max(
+ 0,
+ Math.min(100, cat.defaultPercent + trend + noise),
+ );
+ }
+
+ // Normalize to 100%
+ const total = Object.values(rawAllocations).reduce((a, b) => a + b, 0);
+ const allocations: Record = {};
+ for (const id of Object.keys(rawAllocations)) {
+ allocations[id] = Math.round((rawAllocations[id] / total) * 1000) / 10;
+ }
+
+ months.push({ month: monthStr, allocations });
+ }
+
+ return months;
+}
+
+// ---------------------------------------------------------------------------
+// Response Formatting
+// ---------------------------------------------------------------------------
+
+function formatBudgetSummary(data: BudgetDataResponse): string {
+ const lines: string[] = [
+ "Budget Allocator Configuration",
+ "==============================",
+ "",
+ `Default Budget: ${data.config.currencySymbol}${data.config.defaultBudget.toLocaleString()}`,
+ `Available Presets: ${data.config.presetBudgets.map((b) => `${data.config.currencySymbol}${b.toLocaleString()}`).join(", ")}`,
+ "",
+ "Categories:",
+ ...data.config.categories.map(
+ (c) => ` - ${c.name}: ${c.defaultPercent}% default`,
+ ),
+ "",
+ `Historical Data: ${data.analytics.history.length} months`,
+ `Benchmark Stages: ${data.analytics.stages.join(", ")}`,
+ `Default Stage: ${data.analytics.defaultStage}`,
+ ];
+ return lines.join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// MCP Server Setup
+// ---------------------------------------------------------------------------
+
+const server = new McpServer({
+ name: "Budget Allocator Server",
+ version: "1.0.0",
+});
+
+const resourceUri = "ui://budget-allocator/mcp-app.html";
+
+server.registerTool(
+ "get-budget-data",
+ {
+ title: "Get Budget Data",
+ description:
+ "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
+ inputSchema: {},
+ outputSchema: BudgetDataResponseSchema.shape,
+ _meta: { [RESOURCE_URI_META_KEY]: resourceUri },
+ },
+ async (): Promise => {
+ const response: BudgetDataResponse = {
+ config: {
+ categories: CATEGORIES.map(({ id, name, color, defaultPercent }) => ({
+ id,
+ name,
+ color,
+ defaultPercent,
+ })),
+ presetBudgets: [50000, 100000, 250000, 500000],
+ defaultBudget: 100000,
+ currency: "USD",
+ currencySymbol: "$",
+ },
+ analytics: {
+ history: generateHistory(CATEGORIES),
+ benchmarks: BENCHMARKS,
+ stages: ["Seed", "Series A", "Series B", "Growth"],
+ defaultStage: "Series A",
+ },
+ };
+
+ return {
+ content: [
+ {
+ type: "text",
+ text: formatBudgetSummary(response),
+ },
+ ],
+ structuredContent: response,
+ };
+ },
+);
+
+server.registerResource(
+ resourceUri,
+ resourceUri,
+ { description: "Interactive Budget Allocator UI" },
+ async (): Promise => {
+ const html = await fs.readFile(
+ path.join(DIST_DIR, "mcp-app.html"),
+ "utf-8",
+ );
+ return {
+ contents: [{ uri: resourceUri, mimeType: "text/html+mcp", text: html }],
+ };
+ },
+);
+
+// ---------------------------------------------------------------------------
+// Express Server
+// ---------------------------------------------------------------------------
+
+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(
+ `Budget Allocator 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/budget-allocator-server/src/global.css b/examples/budget-allocator-server/src/global.css
new file mode 100644
index 0000000..c9268f0
--- /dev/null
+++ b/examples/budget-allocator-server/src/global.css
@@ -0,0 +1,14 @@
+* {
+ box-sizing: border-box;
+}
+
+html, body {
+ font-family: system-ui, -apple-system, sans-serif;
+ font-size: 14px;
+ margin: 0;
+ padding: 0;
+}
+
+code {
+ font-size: 1em;
+}
diff --git a/examples/budget-allocator-server/src/mcp-app.css b/examples/budget-allocator-server/src/mcp-app.css
new file mode 100644
index 0000000..93caf9f
--- /dev/null
+++ b/examples/budget-allocator-server/src/mcp-app.css
@@ -0,0 +1,459 @@
+/* CSS Variables for theming */
+:root {
+ --color-bg: #ffffff;
+ --color-text: #1f2937;
+ --color-text-muted: #6b7280;
+ --color-border: #e5e7eb;
+ --color-success: #10b981;
+ --color-warning: #f59e0b;
+ --color-danger: #ef4444;
+ --color-info: #3b82f6;
+ --slider-track: #e5e7eb;
+ --slider-thumb: #3b82f6;
+ --slider-thumb-size: 16px;
+ --card-bg: #f9fafb;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-bg: #111827;
+ --color-text: #f9fafb;
+ --color-text-muted: #9ca3af;
+ --color-border: #374151;
+ --color-success: #34d399;
+ --color-warning: #fbbf24;
+ --color-danger: #f87171;
+ --color-info: #60a5fa;
+ --slider-track: #374151;
+ --slider-thumb: #60a5fa;
+ --card-bg: #1f2937;
+ }
+}
+
+body {
+ background: var(--color-bg);
+ color: var(--color-text);
+}
+
+/* App Container - Prefers 600px but shrinks responsively */
+.app-container {
+ display: flex;
+ flex-direction: column;
+ height: 600px;
+ width: 600px;
+ max-width: 100%;
+ max-height: 100%;
+ overflow: hidden;
+ padding: 12px;
+ gap: 8px;
+ box-sizing: border-box;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
+/* Header */
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-shrink: 0;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.budget-select {
+ padding: 6px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.budget-select:focus {
+ outline: 2px solid var(--color-info);
+ outline-offset: 1px;
+}
+
+/* Chart Section */
+.chart-section {
+ flex: 0 0 160px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 4px;
+}
+
+#budget-chart {
+ max-width: 150px;
+ max-height: 150px;
+}
+
+/* Sliders Section */
+.sliders-section {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ overflow: visible;
+ min-height: 0;
+}
+
+/* Slider Row */
+.slider-row {
+ display: grid;
+ grid-template-columns: 95px 50px minmax(60px, 1fr) 56px 46px;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 8px;
+ border-radius: 6px;
+ background: var(--card-bg);
+ transition: background-color 0.15s ease;
+}
+
+.slider-row.highlighted {
+ background: var(--color-border);
+}
+
+/* Responsive: narrower screens */
+@media (max-width: 500px) {
+ .app-container {
+ padding: 8px;
+ gap: 6px;
+ }
+
+ .header {
+ padding-bottom: 6px;
+ }
+
+ .title {
+ font-size: 16px;
+ }
+
+ .chart-section {
+ flex: 0 0 120px;
+ }
+
+ #budget-chart {
+ max-width: 110px;
+ max-height: 110px;
+ }
+
+ .slider-row {
+ grid-template-columns: 85px 40px minmax(30px, 1fr) 48px 38px;
+ gap: 4px;
+ padding: 3px 4px;
+ }
+
+ .slider-label {
+ font-size: 12px;
+ gap: 4px;
+ }
+
+ .color-dot {
+ width: 8px;
+ height: 8px;
+ }
+
+ .sparkline {
+ width: 32px;
+ height: 16px;
+ }
+
+ .slider-value .percent {
+ font-size: 11px;
+ }
+
+ .slider-value .amount {
+ font-size: 9px;
+ }
+
+ .percentile-badge {
+ font-size: 9px;
+ padding: 2px 4px;
+ }
+
+ .status-bar {
+ font-size: 11px;
+ padding: 6px 8px;
+ }
+
+ .comparison-bar {
+ font-size: 10px;
+ padding: 6px 8px;
+ flex-wrap: wrap;
+ gap: 4px;
+ }
+}
+
+/* Very narrow screens: hide sparklines */
+@media (max-width: 380px) {
+ .slider-row {
+ grid-template-columns: 80px minmax(30px, 1fr) 44px 36px;
+ }
+
+ .sparkline-wrapper {
+ display: none;
+ }
+
+ .comparison-bar {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
+/* Slider Label */
+.slider-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.color-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background-color: var(--category-color);
+ flex-shrink: 0;
+}
+
+.label-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Sparkline */
+.sparkline {
+ width: 50px;
+ height: 28px;
+ border-radius: 3px;
+ background: var(--color-border);
+ cursor: help;
+}
+
+.sparkline-wrapper {
+ position: relative;
+}
+
+.sparkline-wrapper:hover .sparkline-tooltip {
+ opacity: 1;
+ visibility: visible;
+}
+
+.sparkline-tooltip {
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 5px 10px;
+ background: var(--color-text);
+ color: var(--color-bg);
+ font-size: 12px;
+ border-radius: 4px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.15s ease;
+ pointer-events: none;
+ z-index: 10;
+ margin-bottom: 4px;
+}
+
+/* Slider Container - pad left to prevent thumb bleeding into sparkline */
+.slider-container {
+ overflow: visible;
+ padding: 4px 0 4px calc(var(--slider-thumb-size) / 2);
+}
+
+/* Range Slider */
+.slider {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 6px;
+ border-radius: 3px;
+ background: var(--slider-track);
+ outline: none;
+ cursor: pointer;
+}
+
+.slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: var(--slider-thumb-size);
+ height: var(--slider-thumb-size);
+ border-radius: 50%;
+ background: var(--slider-thumb);
+ cursor: pointer;
+ transition: transform 0.1s ease;
+ border: 2px solid var(--color-bg);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+}
+
+.slider::-webkit-slider-thumb:hover {
+ transform: scale(1.15);
+}
+
+.slider::-moz-range-thumb {
+ width: var(--slider-thumb-size);
+ height: var(--slider-thumb-size);
+ border-radius: 50%;
+ background: var(--slider-thumb);
+ cursor: pointer;
+ border: 2px solid var(--color-bg);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+}
+
+.slider:focus {
+ outline: none;
+}
+
+.slider:focus::-webkit-slider-thumb {
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
+}
+
+/* Slider Value Display */
+.slider-value {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.slider-value .percent {
+ display: block;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.slider-value .amount {
+ display: block;
+ font-size: 12px;
+ color: var(--color-text-muted);
+}
+
+/* Percentile Badge */
+.percentile-badge {
+ font-size: 12px;
+ font-weight: 500;
+ padding: 3px 6px;
+ border-radius: 4px;
+ text-align: center;
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+}
+
+.percentile-icon {
+ font-size: 8px;
+}
+
+.percentile-normal {
+ color: var(--color-success);
+ background: rgba(16, 185, 129, 0.15);
+}
+
+.percentile-high {
+ color: var(--color-info);
+ background: rgba(59, 130, 246, 0.15);
+}
+
+.percentile-low {
+ color: var(--color-warning);
+ background: rgba(245, 158, 11, 0.15);
+}
+
+/* Footer */
+.footer {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+/* Status Bar */
+.status-bar {
+ padding: 10px 12px;
+ border-radius: 6px;
+ text-align: center;
+ font-size: 15px;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.status-balanced {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--color-success);
+}
+
+.status-warning {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning);
+}
+
+.status-warning.status-over {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--color-danger);
+}
+
+.status-icon {
+ font-size: 12px;
+}
+
+/* Comparison Bar */
+.comparison-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background: var(--card-bg);
+ border-radius: 6px;
+ font-size: 12px;
+ color: var(--color-text-muted);
+}
+
+.comparison-highlight {
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.stage-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+}
+
+.stage-select {
+ padding: 4px 8px;
+ border: 1px solid var(--color-border);
+ border-radius: 4px;
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.stage-select:focus {
+ outline: 2px solid var(--color-info);
+ outline-offset: 1px;
+}
diff --git a/examples/budget-allocator-server/src/mcp-app.ts b/examples/budget-allocator-server/src/mcp-app.ts
new file mode 100644
index 0000000..c267221
--- /dev/null
+++ b/examples/budget-allocator-server/src/mcp-app.ts
@@ -0,0 +1,628 @@
+/**
+ * Budget Allocator App - Interactive budget allocation with real-time visualization
+ */
+import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
+import { Chart, registerables } from "chart.js";
+import "./global.css";
+import "./mcp-app.css";
+
+// Register Chart.js components
+Chart.register(...registerables);
+
+const log = {
+ info: console.log.bind(console, "[APP]"),
+ error: console.error.bind(console, "[APP]"),
+};
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface BudgetCategory {
+ id: string;
+ name: string;
+ color: string;
+ defaultPercent: number;
+}
+
+interface BenchmarkPercentiles {
+ p25: number;
+ p50: number;
+ p75: number;
+}
+
+interface StageBenchmark {
+ stage: string;
+ categoryBenchmarks: Record;
+}
+
+interface BudgetConfig {
+ categories: BudgetCategory[];
+ presetBudgets: number[];
+ defaultBudget: number;
+ currency: string;
+ currencySymbol: string;
+}
+
+interface HistoricalMonth {
+ month: string;
+ allocations: Record;
+}
+
+interface BudgetAnalytics {
+ history: HistoricalMonth[];
+ benchmarks: StageBenchmark[];
+ stages: string[];
+ defaultStage: string;
+}
+
+interface BudgetDataResponse {
+ config: BudgetConfig;
+ analytics: BudgetAnalytics;
+}
+
+interface AppState {
+ config: BudgetConfig | null;
+ analytics: BudgetAnalytics | null;
+ totalBudget: number;
+ allocations: Map;
+ selectedStage: string;
+ chart: Chart<"doughnut"> | null;
+}
+
+// ---------------------------------------------------------------------------
+// State
+// ---------------------------------------------------------------------------
+
+const state: AppState = {
+ config: null,
+ analytics: null,
+ totalBudget: 100000,
+ allocations: new Map(),
+ selectedStage: "Series A",
+ chart: null,
+};
+
+// ---------------------------------------------------------------------------
+// DOM References
+// ---------------------------------------------------------------------------
+
+const budgetSelector = document.getElementById(
+ "budget-selector",
+) as HTMLSelectElement;
+const stageSelector = document.getElementById(
+ "stage-selector",
+) as HTMLSelectElement;
+const slidersContainer = document.getElementById("sliders-container")!;
+const statusBar = document.getElementById("status-bar")!;
+const comparisonSummary = document.getElementById("comparison-summary")!;
+const chartCanvas = document.getElementById(
+ "budget-chart",
+) as HTMLCanvasElement;
+
+// ---------------------------------------------------------------------------
+// Utility Functions
+// ---------------------------------------------------------------------------
+
+function formatCurrency(amount: number): string {
+ const symbol = state.config?.currencySymbol ?? "$";
+ if (amount >= 1000) {
+ return `${symbol}${Math.round(amount / 1000)}K`;
+ }
+ return `${symbol}${amount.toLocaleString()}`;
+}
+
+function formatCurrencyFull(amount: number): string {
+ const symbol = state.config?.currencySymbol ?? "$";
+ return `${symbol}${amount.toLocaleString()}`;
+}
+
+// ---------------------------------------------------------------------------
+// Sparkline Rendering (Custom Canvas)
+// ---------------------------------------------------------------------------
+
+function drawSparkline(
+ canvas: HTMLCanvasElement,
+ data: number[],
+ color: string,
+): void {
+ const ctx = canvas.getContext("2d");
+ if (!ctx || data.length < 2) return;
+
+ const width = canvas.width;
+ const height = canvas.height;
+ const padding = 2;
+
+ // Clear canvas
+ ctx.clearRect(0, 0, width, height);
+
+ // Calculate min/max for scaling
+ const min = Math.min(...data) - 2;
+ const max = Math.max(...data) + 2;
+ const range = max - min || 1;
+
+ // Draw area fill
+ ctx.beginPath();
+ ctx.moveTo(padding, height - padding);
+
+ data.forEach((value, i) => {
+ const x = padding + (i / (data.length - 1)) * (width - 2 * padding);
+ const y =
+ height - padding - ((value - min) / range) * (height - 2 * padding);
+ ctx.lineTo(x, y);
+ });
+
+ ctx.lineTo(width - padding, height - padding);
+ ctx.closePath();
+ ctx.fillStyle = `${color}20`; // 12.5% opacity
+ ctx.fill();
+
+ // Draw line
+ ctx.beginPath();
+ data.forEach((value, i) => {
+ const x = padding + (i / (data.length - 1)) * (width - 2 * padding);
+ const y =
+ height - padding - ((value - min) / range) * (height - 2 * padding);
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ });
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+}
+
+// ---------------------------------------------------------------------------
+// Percentile Calculation
+// ---------------------------------------------------------------------------
+
+function calculatePercentile(
+ value: number,
+ benchmarks: BenchmarkPercentiles,
+): number {
+ // Interpolate percentile based on value position relative to p25, p50, p75
+ if (value <= benchmarks.p25) {
+ return 25 * (value / benchmarks.p25);
+ }
+ if (value <= benchmarks.p50) {
+ return (
+ 25 + 25 * ((value - benchmarks.p25) / (benchmarks.p50 - benchmarks.p25))
+ );
+ }
+ if (value <= benchmarks.p75) {
+ return (
+ 50 + 25 * ((value - benchmarks.p50) / (benchmarks.p75 - benchmarks.p50))
+ );
+ }
+ // Above p75
+ const extraRange = benchmarks.p75 - benchmarks.p50;
+ return 75 + 25 * Math.min(1, (value - benchmarks.p75) / extraRange);
+}
+
+function getPercentileClass(percentile: number): string {
+ if (percentile >= 40 && percentile <= 60) return "percentile-normal";
+ if (percentile > 60) return "percentile-high";
+ return "percentile-low";
+}
+
+function formatPercentileBadge(percentile: number): string {
+ const rounded = Math.round(percentile);
+ if (percentile >= 40 && percentile <= 60) return `${rounded}th`;
+ if (percentile > 60) return `${rounded}th`;
+ return `${rounded}th`;
+}
+
+function getPercentileIcon(percentile: number): string {
+ if (percentile >= 40 && percentile <= 60) return "";
+ if (percentile > 60) return "";
+ return "";
+}
+
+// ---------------------------------------------------------------------------
+// Chart Initialization
+// ---------------------------------------------------------------------------
+
+function initChart(categories: BudgetCategory[]): Chart<"doughnut"> {
+ const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
+
+ return new Chart(chartCanvas, {
+ type: "doughnut",
+ data: {
+ labels: categories.map((c) => c.name),
+ datasets: [
+ {
+ data: categories.map(
+ (c) => state.allocations.get(c.id) ?? c.defaultPercent,
+ ),
+ backgroundColor: categories.map((c) => c.color),
+ borderWidth: 2,
+ borderColor: isDarkMode ? "#1f2937" : "#ffffff",
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: true,
+ cutout: "60%",
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: (ctx) => {
+ const pct = ctx.parsed;
+ const amt = (pct / 100) * state.totalBudget;
+ return `${ctx.label}: ${pct.toFixed(1)}% (${formatCurrency(amt)})`;
+ },
+ },
+ },
+ },
+ onClick: (_event, elements) => {
+ if (elements.length > 0 && state.config) {
+ const index = elements[0].index;
+ focusSlider(state.config.categories[index].id);
+ }
+ },
+ onHover: (_event, elements) => {
+ if (elements.length > 0 && state.config) {
+ highlightSlider(state.config.categories[elements[0].index].id);
+ } else {
+ clearSliderHighlight();
+ }
+ },
+ },
+ });
+}
+
+function updateChart(): void {
+ if (!state.chart || !state.config) return;
+
+ const data = state.config.categories.map(
+ (c) => state.allocations.get(c.id) ?? 0,
+ );
+ state.chart.data.datasets[0].data = data;
+ state.chart.update("none");
+}
+
+// ---------------------------------------------------------------------------
+// Slider Management
+// ---------------------------------------------------------------------------
+
+function createSliderRow(
+ category: BudgetCategory,
+ historyData: number[],
+): HTMLElement {
+ const allocation =
+ state.allocations.get(category.id) ?? category.defaultPercent;
+ const amount = (allocation / 100) * state.totalBudget;
+
+ // Calculate trend info for tooltip
+ const firstVal = historyData[0] ?? 0;
+ const lastVal = historyData[historyData.length - 1] ?? 0;
+ const trendDiff = lastVal - firstVal;
+ const trendArrow =
+ Math.abs(trendDiff) < 0.5 ? "" : trendDiff > 0 ? " +" : " ";
+ const tooltipText = `Past allocations: ${firstVal.toFixed(0)}%${trendArrow}${trendDiff.toFixed(1)}%`;
+
+ const row = document.createElement("div");
+ row.className = "slider-row";
+ row.dataset.categoryId = category.id;
+
+ row.innerHTML = `
+
+
+ ${category.name}
+
+
+
+ ${tooltipText}
+
+
+
+
+
+ ${allocation.toFixed(1)}%
+ ${formatCurrency(amount)}
+
+
+ `;
+
+ // Draw sparkline
+ const sparklineCanvas = row.querySelector(".sparkline") as HTMLCanvasElement;
+ drawSparkline(sparklineCanvas, historyData, category.color);
+
+ // Slider event listener
+ const slider = row.querySelector(".slider") as HTMLInputElement;
+ slider.addEventListener("input", () => {
+ handleSliderChange(category.id, parseFloat(slider.value));
+ });
+
+ return row;
+}
+
+function handleSliderChange(categoryId: string, newPercent: number): void {
+ state.allocations.set(categoryId, newPercent);
+ updateSliderDisplay(categoryId, newPercent);
+ updateChart();
+ updateStatusBar();
+ updatePercentileBadge(categoryId);
+ updateComparisonSummary();
+}
+
+function updateSliderDisplay(categoryId: string, percent: number): void {
+ const row = document.querySelector(
+ `.slider-row[data-category-id="${categoryId}"]`,
+ );
+ if (!row) return;
+
+ const amount = (percent / 100) * state.totalBudget;
+ const percentEl = row.querySelector(".percent")!;
+ const amountEl = row.querySelector(".amount")!;
+
+ percentEl.textContent = `${percent.toFixed(1)}%`;
+ amountEl.textContent = formatCurrency(amount);
+}
+
+function updateAllSliderAmounts(): void {
+ if (!state.config) return;
+
+ for (const category of state.config.categories) {
+ const percent = state.allocations.get(category.id) ?? 0;
+ updateSliderDisplay(category.id, percent);
+ }
+}
+
+function focusSlider(categoryId: string): void {
+ const slider = document.querySelector(
+ `.slider-row[data-category-id="${categoryId}"] .slider`,
+ ) as HTMLInputElement | null;
+ if (slider) {
+ slider.focus();
+ highlightSlider(categoryId);
+ }
+}
+
+function highlightSlider(categoryId: string): void {
+ clearSliderHighlight();
+ const row = document.querySelector(
+ `.slider-row[data-category-id="${categoryId}"]`,
+ );
+ if (row) {
+ row.classList.add("highlighted");
+ }
+}
+
+function clearSliderHighlight(): void {
+ document
+ .querySelectorAll(".slider-row.highlighted")
+ .forEach((el) => el.classList.remove("highlighted"));
+}
+
+// ---------------------------------------------------------------------------
+// Percentile Badge Updates
+// ---------------------------------------------------------------------------
+
+function updatePercentileBadge(categoryId: string): void {
+ if (!state.analytics || !state.config) return;
+
+ const stageBenchmark = state.analytics.benchmarks.find(
+ (b) => b.stage === state.selectedStage,
+ );
+ if (!stageBenchmark) return;
+
+ const category = state.config.categories.find((c) => c.id === categoryId);
+ if (!category) return;
+
+ const currentAllocation =
+ state.allocations.get(categoryId) ?? category.defaultPercent;
+ const benchmarks = stageBenchmark.categoryBenchmarks[categoryId];
+ if (!benchmarks) return;
+
+ const badge = document.querySelector(
+ `.slider-row[data-category-id="${categoryId}"] .percentile-badge`,
+ );
+ if (!badge) return;
+
+ const percentile = calculatePercentile(currentAllocation, benchmarks);
+ badge.className = `percentile-badge ${getPercentileClass(percentile)}`;
+ badge.innerHTML = `${getPercentileIcon(percentile)} ${formatPercentileBadge(percentile)}`;
+}
+
+function updateAllPercentileBadges(): void {
+ if (!state.config) return;
+ for (const category of state.config.categories) {
+ updatePercentileBadge(category.id);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Status Bar
+// ---------------------------------------------------------------------------
+
+function updateStatusBar(): void {
+ const total = Array.from(state.allocations.values()).reduce(
+ (sum, v) => sum + v,
+ 0,
+ );
+ const allocated = (total / 100) * state.totalBudget;
+ const isBalanced = Math.abs(total - 100) < 0.1;
+
+ let statusIcon: string;
+ let statusClass: string;
+
+ if (isBalanced) {
+ statusIcon = "";
+ statusClass = "status-balanced";
+ } else if (total > 100) {
+ statusIcon = " Over";
+ statusClass = "status-warning status-over";
+ } else {
+ statusIcon = " Under";
+ statusClass = "status-warning status-under";
+ }
+
+ statusBar.innerHTML = `
+ Allocated: ${formatCurrencyFull(allocated)} / ${formatCurrencyFull(state.totalBudget)}
+ ${statusIcon}
+ `;
+ statusBar.className = `status-bar ${statusClass}`;
+}
+
+// ---------------------------------------------------------------------------
+// Comparison Summary
+// ---------------------------------------------------------------------------
+
+function updateComparisonSummary(): void {
+ if (!state.analytics || !state.config) return;
+
+ const stageBenchmark = state.analytics.benchmarks.find(
+ (b) => b.stage === state.selectedStage,
+ );
+ if (!stageBenchmark) return;
+
+ // Find most notable deviation
+ let maxDeviation = 0;
+ let maxDeviationCategory: BudgetCategory | null = null;
+ let maxDeviationDirection = "";
+
+ for (const category of state.config.categories) {
+ const allocation =
+ state.allocations.get(category.id) ?? category.defaultPercent;
+ const benchmark = stageBenchmark.categoryBenchmarks[category.id];
+ if (!benchmark) continue;
+
+ const deviation = allocation - benchmark.p50;
+ if (Math.abs(deviation) > Math.abs(maxDeviation)) {
+ maxDeviation = deviation;
+ maxDeviationCategory = category;
+ maxDeviationDirection = deviation > 0 ? "above" : "below";
+ }
+ }
+
+ if (maxDeviationCategory && Math.abs(maxDeviation) > 3) {
+ comparisonSummary.innerHTML = `
+ vs. Industry: ${maxDeviationCategory.name}
+ ${maxDeviation > 0 ? "" : ""} ${Math.abs(Math.round(maxDeviation))}% ${maxDeviationDirection} avg
+ `;
+ } else {
+ comparisonSummary.textContent = "vs. Industry: similar to peers";
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Selector Initialization
+// ---------------------------------------------------------------------------
+
+function initBudgetSelector(presets: number[], defaultBudget: number): void {
+ budgetSelector.innerHTML = "";
+
+ for (const amount of presets) {
+ const option = document.createElement("option");
+ option.value = amount.toString();
+ option.textContent = formatCurrencyFull(amount);
+ option.selected = amount === defaultBudget;
+ budgetSelector.appendChild(option);
+ }
+
+ budgetSelector.addEventListener("change", () => {
+ state.totalBudget = parseInt(budgetSelector.value);
+ updateAllSliderAmounts();
+ updateStatusBar();
+ });
+}
+
+function initStageSelector(stages: string[], defaultStage: string): void {
+ stageSelector.innerHTML = "";
+
+ for (const stage of stages) {
+ const option = document.createElement("option");
+ option.value = stage;
+ option.textContent = stage;
+ option.selected = stage === defaultStage;
+ stageSelector.appendChild(option);
+ }
+
+ stageSelector.addEventListener("change", () => {
+ state.selectedStage = stageSelector.value;
+ updateAllPercentileBadges();
+ updateComparisonSummary();
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Main Initialization
+// ---------------------------------------------------------------------------
+
+function initializeUI(config: BudgetConfig, analytics: BudgetAnalytics): void {
+ state.config = config;
+ state.analytics = analytics;
+ state.totalBudget = config.defaultBudget;
+ state.selectedStage = analytics.defaultStage;
+
+ // Initialize allocations with defaults
+ for (const category of config.categories) {
+ state.allocations.set(category.id, category.defaultPercent);
+ }
+
+ // Initialize selectors
+ initBudgetSelector(config.presetBudgets, config.defaultBudget);
+ initStageSelector(analytics.stages, analytics.defaultStage);
+
+ // Create slider rows
+ slidersContainer.innerHTML = "";
+ for (const category of config.categories) {
+ const historyData = analytics.history.map(
+ (h) => h.allocations[category.id] ?? 0,
+ );
+ const row = createSliderRow(category, historyData);
+ slidersContainer.appendChild(row);
+ }
+
+ // Initialize chart
+ state.chart = initChart(config.categories);
+
+ // Update all displays
+ updateAllPercentileBadges();
+ updateStatusBar();
+ updateComparisonSummary();
+
+ log.info(
+ "UI initialized with",
+ analytics.history.length,
+ "months of history",
+ );
+}
+
+// ---------------------------------------------------------------------------
+// App Connection
+// ---------------------------------------------------------------------------
+
+const app = new App({ name: "Budget Allocator", version: "1.0.0" });
+
+app.ontoolresult = (result) => {
+ log.info("Received tool result:", result);
+ const data = result.structuredContent as unknown as BudgetDataResponse;
+ if (data?.config && data?.analytics) {
+ initializeUI(data.config, data.analytics);
+ }
+};
+
+app.onerror = log.error;
+
+// Handle theme changes
+window
+ .matchMedia("(prefers-color-scheme: dark)")
+ .addEventListener("change", () => {
+ if (state.chart && state.config) {
+ state.chart.destroy();
+ state.chart = initChart(state.config.categories);
+ }
+ });
+
+// Connect to host
+app.connect(new PostMessageTransport(window.parent));
diff --git a/examples/budget-allocator-server/tsconfig.json b/examples/budget-allocator-server/tsconfig.json
new file mode 100644
index 0000000..535267b
--- /dev/null
+++ b/examples/budget-allocator-server/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/budget-allocator-server/vite.config.ts b/examples/budget-allocator-server/vite.config.ts
new file mode 100644
index 0000000..6ff6d99
--- /dev/null
+++ b/examples/budget-allocator-server/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 eb3ea40..0529bb7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -109,6 +109,26 @@
"vite-plugin-singlefile": "^2.3.0"
}
},
+ "examples/budget-allocator-server": {
+ "version": "1.0.0",
+ "dependencies": {
+ "@modelcontextprotocol/ext-apps": "../..",
+ "@modelcontextprotocol/sdk": "^1.22.0",
+ "chart.js": "^4.4.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"
+ }
+ },
"examples/cohort-heatmap-server": {
"version": "1.0.0",
"dependencies": {
@@ -1211,6 +1231,10 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/budget-allocator-server": {
+ "resolved": "examples/budget-allocator-server",
+ "link": true
+ },
"node_modules/bun": {
"version": "1.3.3",
"cpu": [