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 + + +
+
+

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 = ` + +
+ + ${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": [