diff --git a/docs/quickstart.md b/docs/quickstart.md index 79a1cfa3..115219ca 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -132,7 +132,9 @@ server.registerResource(resourceUri, resourceUri, {}, async () => { "utf-8", ); return { - contents: [{ uri: resourceUri, mimeType: "text/html+mcp", text: html }], + contents: [ + { uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html }, + ], }; }); diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 8761d799..c09561cf 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -106,9 +106,9 @@ async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise + + + + + Budget Allocator + + +
+
+

Budget Allocator

+ +
+ +
+ +
+ +
+ +
+
+ Allocated: $0 / $0 +
+
+ Loading benchmarks... + +
+
+
+ + + diff --git a/examples/budget-allocator-server/package.json b/examples/budget-allocator-server/package.json new file mode 100644 index 00000000..5493b52d --- /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 00000000..9db094d6 --- /dev/null +++ b/examples/budget-allocator-server/server.ts @@ -0,0 +1,367 @@ +/** + * 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;profile=mcp-app", 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 00000000..c9268f0e --- /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 00000000..93caf9f7 --- /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 00000000..c2672216 --- /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 00000000..535267b2 --- /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 00000000..6ff6d997 --- /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/examples/cohort-heatmap-server/README.md b/examples/cohort-heatmap-server/README.md new file mode 100644 index 00000000..be81c061 --- /dev/null +++ b/examples/cohort-heatmap-server/README.md @@ -0,0 +1,50 @@ +# Example: Cohort Heatmap App + +A demo MCP App that displays cohort retention data as an interactive heatmap, showing customer retention over time by signup month. + +## Features + +- **Cohort Retention Heatmap**: Color-coded grid showing retention percentages across cohorts and time periods +- **Multiple Metrics**: Switch between Retention %, Revenue Retention, and Active Users +- **Period Types**: View data by monthly or weekly intervals +- **Interactive Exploration**: Hover cells for detailed tooltips, click to highlight rows/columns +- **Color Scale**: Green (high retention) through yellow to red (low retention) +- **Theme Support**: Adapts to light/dark mode preferences + +## 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-cohort-data` tool that returns: + +- Cohort rows with signup month, original user count, and retention cells +- Period headers and labels +- Configurable parameters: metric type, period type, cohort count, max periods + +The tool generates synthetic cohort data using an exponential decay model with configurable retention curves per metric type. + +### App (`src/mcp-app.tsx`) + +- Uses React for the heatmap visualization +- Fetches data on mount and when filters change +- Displays retention percentages in a grid with HSL color interpolation +- Shows detailed tooltips on hover with user counts and exact retention values +- Supports row/column highlighting on cell click diff --git a/examples/cohort-heatmap-server/mcp-app.html b/examples/cohort-heatmap-server/mcp-app.html new file mode 100644 index 00000000..2b1a47d3 --- /dev/null +++ b/examples/cohort-heatmap-server/mcp-app.html @@ -0,0 +1,13 @@ + + + + + + Cohort Retention Heatmap + + + +
+ + + diff --git a/examples/cohort-heatmap-server/package.json b/examples/cohort-heatmap-server/package.json new file mode 100644 index 00000000..6769b1c6 --- /dev/null +++ b/examples/cohort-heatmap-server/package.json @@ -0,0 +1,34 @@ +{ + "name": "cohort-heatmap-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", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "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/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts new file mode 100644 index 00000000..0c2a3a54 --- /dev/null +++ b/examples/cohort-heatmap-server/server.ts @@ -0,0 +1,262 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { 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 GetCohortDataInputSchema = z.object({ + metric: z + .enum(["retention", "revenue", "active"]) + .optional() + .default("retention"), + periodType: z.enum(["monthly", "weekly"]).optional().default("monthly"), + cohortCount: z.number().min(3).max(24).optional().default(12), + maxPeriods: z.number().min(3).max(24).optional().default(12), +}); + +const CohortCellSchema = z.object({ + cohortIndex: z.number(), + periodIndex: z.number(), + retention: z.number(), + usersRetained: z.number(), + usersOriginal: z.number(), +}); + +const CohortRowSchema = z.object({ + cohortId: z.string(), + cohortLabel: z.string(), + originalUsers: z.number(), + cells: z.array(CohortCellSchema), +}); + +const CohortDataSchema = z.object({ + cohorts: z.array(CohortRowSchema), + periods: z.array(z.string()), + periodLabels: z.array(z.string()), + metric: z.string(), + periodType: z.string(), + generatedAt: z.string(), +}); + +// Types derived from schemas +type CohortCell = z.infer; +type CohortRow = z.infer; +type CohortData = z.infer; + +// Internal types (not part of API schema) +interface RetentionParams { + baseRetention: number; + decayRate: number; + floor: number; + noise: number; +} + +// Retention curve generator using exponential decay +function generateRetention(period: number, params: RetentionParams): number { + if (period === 0) return 1.0; + + const { baseRetention, decayRate, floor, noise } = params; + const base = baseRetention * Math.exp(-decayRate * (period - 1)) + floor; + const variation = (Math.random() - 0.5) * 2 * noise; + + return Math.max(0, Math.min(1, base + variation)); +} + +// Generate cohort data +function generateCohortData( + metric: string, + periodType: string, + cohortCount: number, + maxPeriods: number, +): CohortData { + const now = new Date(); + const cohorts: CohortRow[] = []; + const periods: string[] = []; + const periodLabels: string[] = []; + + // Generate period headers + for (let i = 0; i < maxPeriods; i++) { + periods.push(`M${i}`); + periodLabels.push(i === 0 ? "Month 0" : `Month ${i}`); + } + + // Retention parameters vary by metric type + const paramsMap: Record = { + retention: { + baseRetention: 0.75, + decayRate: 0.12, + floor: 0.08, + noise: 0.04, + }, + revenue: { baseRetention: 0.7, decayRate: 0.1, floor: 0.15, noise: 0.06 }, + active: { baseRetention: 0.6, decayRate: 0.18, floor: 0.05, noise: 0.05 }, + }; + const params = paramsMap[metric] ?? paramsMap.retention; + + // Generate cohorts (oldest first) + for (let c = 0; c < cohortCount; c++) { + const cohortDate = new Date(now); + cohortDate.setMonth(cohortDate.getMonth() - (cohortCount - 1 - c)); + + const cohortId = `${cohortDate.getFullYear()}-${String(cohortDate.getMonth() + 1).padStart(2, "0")}`; + const cohortLabel = cohortDate.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }); + + // Random cohort size: 1000-5000 users + const originalUsers = Math.floor(1000 + Math.random() * 4000); + + // Number of periods this cohort has data for (newer cohorts have fewer periods) + const periodsAvailable = cohortCount - c; + + const cells: CohortCell[] = []; + let previousRetention = 1.0; + + for (let p = 0; p < Math.min(periodsAvailable, maxPeriods); p++) { + // Retention must decrease or stay same (with small exceptions for noise) + let retention = generateRetention(p, params); + retention = Math.min(retention, previousRetention + 0.02); + previousRetention = retention; + + cells.push({ + cohortIndex: c, + periodIndex: p, + retention, + usersRetained: Math.round(originalUsers * retention), + usersOriginal: originalUsers, + }); + } + + cohorts.push({ cohortId, cohortLabel, originalUsers, cells }); + } + + return { + cohorts, + periods, + periodLabels, + metric, + periodType, + generatedAt: new Date().toISOString(), + }; +} + +function formatCohortSummary(data: CohortData): string { + const avgRetention = data.cohorts + .flatMap((c) => c.cells) + .filter((cell) => cell.periodIndex > 0) + .reduce((sum, cell, _, arr) => sum + cell.retention / arr.length, 0); + + return `Cohort Analysis: ${data.cohorts.length} cohorts, ${data.periods.length} periods +Average retention: ${(avgRetention * 100).toFixed(1)}% +Metric: ${data.metric}, Period: ${data.periodType}`; +} + +const server = new McpServer({ + name: "Cohort Heatmap Server", + version: "1.0.0", +}); + +// Register tool and resource +{ + const resourceUri = "ui://get-cohort-data/mcp-app.html"; + + server.registerTool( + "get-cohort-data", + { + title: "Get Cohort Retention Data", + description: + "Returns cohort retention heatmap data showing customer retention over time by signup month", + inputSchema: GetCohortDataInputSchema.shape, + outputSchema: CohortDataSchema.shape, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async ({ metric, periodType, cohortCount, maxPeriods }) => { + const data = generateCohortData( + metric, + periodType, + cohortCount, + maxPeriods, + ); + + return { + content: [{ type: "text", text: formatCohortSummary(data) }], + structuredContent: data, + }; + }, + ); + + server.registerResource( + resourceUri, + resourceUri, + {}, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + + return { + contents: [ + { + uri: resourceUri, + mimeType: "text/html;profile=mcp-app", + text: html, + }, + ], + }; + }, + ); +} + +const app = express(); +app.use(cors()); +app.use(express.json()); + +app.post("/mcp", async (req: Request, res: Response) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { + transport.close(); + }); + + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } +}); + +const httpServer = app.listen(PORT, () => { + console.log(`Server listening on http://localhost:${PORT}/mcp`); +}); + +function shutdown() { + console.log("\nShutting down..."); + httpServer.close(() => { + console.log("Server closed"); + process.exit(0); + }); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/examples/cohort-heatmap-server/src/global.css b/examples/cohort-heatmap-server/src/global.css new file mode 100644 index 00000000..b53cf5eb --- /dev/null +++ b/examples/cohort-heatmap-server/src/global.css @@ -0,0 +1,10 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; + margin: 0; + padding: 0; +} diff --git a/examples/cohort-heatmap-server/src/mcp-app.module.css b/examples/cohort-heatmap-server/src/mcp-app.module.css new file mode 100644 index 00000000..cf5dccab --- /dev/null +++ b/examples/cohort-heatmap-server/src/mcp-app.module.css @@ -0,0 +1,238 @@ +/* CSS Variables for theming */ +.container { + --color-bg: #ffffff; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-border: #e5e7eb; + + /* Retention colors */ + --color-retention-high: #22c55e; + --color-retention-medium: #eab308; + --color-retention-low: #f97316; + --color-retention-critical: #ef4444; + + --color-highlight: rgba(59, 130, 246, 0.2); +} + +@media (prefers-color-scheme: dark) { + .container { + --color-bg: #111827; + --color-text: #f9fafb; + --color-text-muted: #9ca3af; + --color-border: #374151; + --color-highlight: rgba(96, 165, 250, 0.25); + } +} + +/* Layout */ +.container { + display: flex; + flex-direction: column; + height: 600px; + max-height: 600px; + overflow: hidden; + padding: 12px; + gap: 8px; + background: var(--color-bg); + color: var(--color-text); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + flex-wrap: wrap; + gap: 8px; +} + +.title { + font-size: 16px; + font-weight: 600; + margin: 0; + white-space: nowrap; +} + +.controls { + display: flex; + gap: 12px; +} + +.control { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.control select { + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg); + color: var(--color-text); + font-size: inherit; +} + +/* Heatmap Grid */ +.heatmapWrapper { + flex: 1; + overflow-x: auto; + overflow-y: hidden; +} + +.heatmapGrid { + display: grid; + gap: 2px; + width: max-content; +} + +.headerCorner { + width: 120px; +} + +.headerPeriod { + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted); + height: 20px; +} + +.headerPeriod.highlighted { + background: var(--color-highlight); + border-radius: 4px; +} + +/* Row Labels */ +.label { + display: flex; + flex-direction: column; + justify-content: center; + padding-right: 8px; + width: 120px; +} + +.label.highlighted { + background: var(--color-highlight); + border-radius: 4px; +} + +.cohortName { + font-weight: 600; + font-size: 13px; +} + +.cohortSize { + font-size: 11px; + color: var(--color-text-muted); +} + +/* Data Cells */ +.cell { + width: 44px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + border-radius: 3px; + cursor: pointer; + transition: transform 0.1s, box-shadow 0.1s; +} + +.cell:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 10; +} + +.cell.highlighted { + outline: 2px solid var(--color-text); + outline-offset: 1px; +} + +.cellEmpty { + width: 44px; + height: 32px; + background-color: var(--color-border); + border-radius: 3px; + opacity: 0.3; +} + +/* Legend */ +.legend { + flex-shrink: 0; + display: flex; + justify-content: center; + gap: 16px; + font-size: 12px; + padding-top: 8px; + border-top: 1px solid var(--color-border); +} + +.legendItem { + display: flex; + align-items: center; + gap: 6px; +} + +.legendColor { + width: 16px; + height: 16px; + border-radius: 3px; +} + +/* Tooltip */ +.tooltip { + position: fixed; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + pointer-events: none; + z-index: 100; + min-width: 180px; +} + +.tooltipHeader { + font-weight: 600; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); +} + +.tooltipRow { + display: flex; + justify-content: space-between; + font-size: 13px; + margin-top: 4px; +} + +.tooltipLabel { + color: var(--color-text-muted); +} + +.tooltipValue { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* Loading/Error states */ +.loading, .error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 14px; +} + +.error { + color: var(--color-retention-critical); +} diff --git a/examples/cohort-heatmap-server/src/mcp-app.tsx b/examples/cohort-heatmap-server/src/mcp-app.tsx new file mode 100644 index 00000000..e27595cf --- /dev/null +++ b/examples/cohort-heatmap-server/src/mcp-app.tsx @@ -0,0 +1,458 @@ +/** + * Cohort Retention Heatmap - MCP App + * + * Interactive cohort retention analysis heatmap showing customer retention + * over time by signup month. Hover for details, click to drill down. + */ +import type { App } from "@modelcontextprotocol/ext-apps"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import { StrictMode, useCallback, useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import styles from "./mcp-app.module.css"; + +// Types +interface CohortCell { + cohortIndex: number; + periodIndex: number; + retention: number; + usersRetained: number; + usersOriginal: number; +} + +interface CohortRow { + cohortId: string; + cohortLabel: string; + originalUsers: number; + cells: CohortCell[]; +} + +interface CohortData { + cohorts: CohortRow[]; + periods: string[]; + periodLabels: string[]; + metric: string; + periodType: string; + generatedAt: string; +} + +interface TooltipData { + x: number; + y: number; + cohortLabel: string; + periodLabel: string; + retention: number; + usersRetained: number; + usersOriginal: number; +} + +type MetricType = "retention" | "revenue" | "active"; +type PeriodType = "monthly" | "weekly"; + +const IMPLEMENTATION = { name: "Cohort Heatmap", version: "1.0.0" }; + +// Color scale function: Green (high) -> Yellow (medium) -> Red (low) +function getRetentionColor(retention: number): string { + const hue = retention * 120; // 0-120 range (red to green) + const saturation = 70; + const lightness = 45 + (1 - retention) * 15; + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +} + +// Format number with commas +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +// Main App Component +function CohortHeatmapApp() { + const { app, error } = useApp({ + appInfo: IMPLEMENTATION, + capabilities: {}, + }); + + if (error) return
ERROR: {error.message}
; + if (!app) return
Connecting...
; + + return ; +} + +// Inner App with state management +function CohortHeatmapInner({ app }: { app: App }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedMetric, setSelectedMetric] = useState("retention"); + const [selectedPeriodType, setSelectedPeriodType] = + useState("monthly"); + const [highlightedCohort, setHighlightedCohort] = useState( + null, + ); + const [highlightedPeriod, setHighlightedPeriod] = useState( + null, + ); + const [tooltipData, setTooltipData] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const result = await app.callServerTool({ + name: "get-cohort-data", + arguments: { + metric: selectedMetric, + periodType: selectedPeriodType, + cohortCount: 12, + maxPeriods: 12, + }, + }); + setData(result.structuredContent as unknown as CohortData); + } catch (e) { + console.error("Failed to fetch cohort data:", e); + } finally { + setLoading(false); + } + }, [app, selectedMetric, selectedPeriodType]); + + // Fetch data on mount and when filters change + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleCellClick = useCallback( + (cohortIndex: number, periodIndex: number) => { + setHighlightedCohort(cohortIndex); + setHighlightedPeriod(periodIndex); + }, + [], + ); + + const handleMetricChange = useCallback((metric: MetricType) => { + setSelectedMetric(metric); + setHighlightedCohort(null); + setHighlightedPeriod(null); + }, []); + + const handlePeriodTypeChange = useCallback((periodType: PeriodType) => { + setSelectedPeriodType(periodType); + setHighlightedCohort(null); + setHighlightedPeriod(null); + }, []); + + return ( +
+
+ {loading ? ( +
Loading...
+ ) : data ? ( + + ) : ( +
Failed to load data
+ )} + + {tooltipData && } +
+ ); +} + +// Header with controls +interface HeaderProps { + selectedMetric: MetricType; + selectedPeriodType: PeriodType; + onMetricChange: (metric: MetricType) => void; + onPeriodTypeChange: (periodType: PeriodType) => void; +} + +function Header({ + selectedMetric, + selectedPeriodType, + onMetricChange, + onPeriodTypeChange, +}: HeaderProps) { + return ( +
+

Cohort Retention Analysis

+
+ + +
+
+ ); +} + +// Heatmap Grid +interface HeatmapGridProps { + data: CohortData; + highlightedCohort: number | null; + highlightedPeriod: number | null; + onCellClick: (cohortIndex: number, periodIndex: number) => void; + onCellHover: (tooltip: TooltipData | null) => void; +} + +function HeatmapGrid({ + data, + highlightedCohort, + highlightedPeriod, + onCellClick, + onCellHover, +}: HeatmapGridProps) { + const gridStyle = useMemo( + () => ({ + gridTemplateColumns: `120px repeat(${data.periods.length}, 44px)`, + }), + [data.periods.length], + ); + + return ( +
+
+ {/* Header row: empty corner + period labels */} +
+ {data.periods.map((period, i) => ( +
+ {period} +
+ ))} + + {/* Data rows */} + {data.cohorts.map((cohort, cohortIndex) => ( + + ))} +
+
+ ); +} + +// Cohort Row +interface CohortRowProps { + cohort: CohortRow; + cohortIndex: number; + periodCount: number; + periodLabels: string[]; + isHighlighted: boolean; + highlightedPeriod: number | null; + onCellClick: (cohortIndex: number, periodIndex: number) => void; + onCellHover: (tooltip: TooltipData | null) => void; +} + +function CohortRowComponent({ + cohort, + cohortIndex, + periodCount, + periodLabels, + isHighlighted, + highlightedPeriod, + onCellClick, + onCellHover, +}: CohortRowProps) { + return ( + <> +
+ {cohort.cohortLabel} + + {formatNumber(cohort.originalUsers)} + +
+ {Array.from({ length: periodCount }, (_, p) => { + const cellData = cohort.cells.find((c) => c.periodIndex === p); + const isCellHighlighted = isHighlighted || highlightedPeriod === p; + + if (!cellData) { + return
; + } + + return ( + onCellClick(cohortIndex, p)} + onHover={onCellHover} + /> + ); + })} + + ); +} + +// Heatmap Cell +interface HeatmapCellProps { + cellData: CohortCell; + cohort: CohortRow; + periodLabel: string; + isHighlighted: boolean; + onClick: () => void; + onHover: (tooltip: TooltipData | null) => void; +} + +function HeatmapCell({ + cellData, + cohort, + periodLabel, + isHighlighted, + onClick, + onHover, +}: HeatmapCellProps) { + const backgroundColor = useMemo( + () => getRetentionColor(cellData.retention), + [cellData.retention], + ); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + onHover({ + x: rect.right + 8, + y: rect.top, + cohortLabel: cohort.cohortLabel, + periodLabel, + retention: cellData.retention, + usersRetained: cellData.usersRetained, + usersOriginal: cellData.usersOriginal, + }); + }, + [cellData, cohort.cohortLabel, periodLabel, onHover], + ); + + const handleMouseLeave = useCallback(() => { + onHover(null); + }, [onHover]); + + return ( +
+ {Math.round(cellData.retention * 100)} +
+ ); +} + +// Tooltip +function Tooltip({ + x, + y, + cohortLabel, + periodLabel, + retention, + usersRetained, + usersOriginal, +}: TooltipData) { + const style = useMemo(() => { + let left = x; + if (left + 200 > window.innerWidth) { + left = x - 216; + } + return { left, top: y }; + }, [x, y]); + + return ( +
+
+ {cohortLabel} — {periodLabel} +
+
+ Retention: + + {(retention * 100).toFixed(1)}% + +
+
+ Users: + + {formatNumber(usersRetained)} / {formatNumber(usersOriginal)} + +
+
+ ); +} + +// Legend +function Legend() { + return ( +
+ + + 80-100% + + + + 50-79% + + + + 20-49% + + + + 0-19% + +
+ ); +} + +// Entry point +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/cohort-heatmap-server/src/vite-env.d.ts b/examples/cohort-heatmap-server/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/cohort-heatmap-server/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cohort-heatmap-server/tsconfig.json b/examples/cohort-heatmap-server/tsconfig.json new file mode 100644 index 00000000..fc3c2101 --- /dev/null +++ b/examples/cohort-heatmap-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/examples/cohort-heatmap-server/vite.config.ts b/examples/cohort-heatmap-server/vite.config.ts new file mode 100644 index 00000000..da0af84e --- /dev/null +++ b/examples/cohort-heatmap-server/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +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: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/examples/customer-segmentation-server/README.md b/examples/customer-segmentation-server/README.md new file mode 100644 index 00000000..bdcc03be --- /dev/null +++ b/examples/customer-segmentation-server/README.md @@ -0,0 +1,58 @@ +# Example: Customer Segmentation Explorer + +A demo MCP App that displays customer data as an interactive scatter/bubble chart with segment-based clustering. Users can explore different metrics, filter by segment, and click to see detailed customer information. + +## Features + +- **Interactive Scatter Plot**: Bubble chart visualization using Chart.js with configurable X/Y axes +- **Segment Clustering**: 250 customers grouped into 4 segments (Enterprise, Mid-Market, SMB, Startup) +- **Axis Selection**: Choose from 6 metrics for each axis (Revenue, Employees, Account Age, Engagement, Tickets, NPS) +- **Size Mapping**: Optional bubble sizing by a third metric for additional data dimension +- **Legend Filtering**: Click segment pills to show/hide customer groups +- **Detail Panel**: Hover or click customers to see name, segment, revenue, engagement, and NPS +- **Theme Support**: Adapts to light/dark mode preferences + +## 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-customer-data` tool that returns: + +- Array of 250 generated customer records with segment assignments +- Segment summary with counts and colors for each group +- Optional segment filter parameter + +The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`. + +### App (`src/mcp-app.ts`) + +- Uses Chart.js bubble chart for the visualization +- Fetches data once on connection +- Dropdown controls update chart axes and bubble sizing +- Custom legend with clickable segment toggles +- Detail panel updates on hover/click interactions + +### Data Generator (`src/data-generator.ts`) + +- Generates realistic customer data with Gaussian clustering around segment centers +- Each segment has characteristic ranges for revenue, employees, engagement, etc. +- Company names generated from word-list combinations (e.g., "Apex Data Corp") +- Data cached in memory for session consistency diff --git a/examples/customer-segmentation-server/mcp-app.html b/examples/customer-segmentation-server/mcp-app.html new file mode 100644 index 00000000..10e584d1 --- /dev/null +++ b/examples/customer-segmentation-server/mcp-app.html @@ -0,0 +1,70 @@ + + + + + + Customer Segmentation Explorer + + +
+
+

Customer Segmentation

+
+ +
+ + + +
+ +
+
+ +
+
+ +
+
+
+ +
+
+ Hover over a point to see details +
+
+
+ + + + diff --git a/examples/customer-segmentation-server/package.json b/examples/customer-segmentation-server/package.json new file mode 100644 index 00000000..b23129b5 --- /dev/null +++ b/examples/customer-segmentation-server/package.json @@ -0,0 +1,30 @@ +{ + "name": "customer-segmentation-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/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts new file mode 100644 index 00000000..5ed99734 --- /dev/null +++ b/examples/customer-segmentation-server/server.ts @@ -0,0 +1,175 @@ +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"; +import { + generateCustomers, + generateSegmentSummaries, +} from "./src/data-generator.ts"; +import { SEGMENTS, type Customer, type SegmentSummary } from "./src/types.ts"; + +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 GetCustomerDataInputSchema = z.object({ + segment: z + .enum(["All", ...SEGMENTS]) + .optional() + .describe("Filter by segment (default: All)"), +}); + +const CustomerSchema = z.object({ + id: z.string(), + name: z.string(), + segment: z.string(), + annualRevenue: z.number(), + employeeCount: z.number(), + accountAge: z.number(), + engagementScore: z.number(), + supportTickets: z.number(), + nps: z.number(), +}); + +const SegmentSummarySchema = z.object({ + name: z.string(), + count: z.number(), + color: z.string(), +}); + +const GetCustomerDataOutputSchema = z.object({ + customers: z.array(CustomerSchema), + segments: z.array(SegmentSummarySchema), +}); + +// Cache generated data for session consistency +let cachedCustomers: Customer[] | null = null; +let cachedSegments: SegmentSummary[] | null = null; + +function getCustomerData(segmentFilter?: string): { + customers: Customer[]; + segments: SegmentSummary[]; +} { + // Generate data on first call + if (!cachedCustomers) { + cachedCustomers = generateCustomers(250); + cachedSegments = generateSegmentSummaries(cachedCustomers); + } + + // Filter by segment if specified + let customers = cachedCustomers; + if (segmentFilter && segmentFilter !== "All") { + customers = cachedCustomers.filter((c) => c.segment === segmentFilter); + } + + return { + customers, + segments: cachedSegments!, + }; +} + +const server = new McpServer({ + name: "Customer Segmentation Server", + version: "1.0.0", +}); + +// Register the get-customer-data tool and its associated UI resource +{ + const resourceUri = "ui://customer-segmentation/mcp-app.html"; + + server.registerTool( + "get-customer-data", + { + title: "Get Customer Data", + description: + "Returns customer data with segment information for visualization. Optionally filter by segment.", + inputSchema: GetCustomerDataInputSchema.shape, + outputSchema: GetCustomerDataOutputSchema.shape, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async ({ segment }): Promise => { + const data = getCustomerData(segment); + + return { + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + structuredContent: data, + }; + }, + ); + + server.registerResource( + resourceUri, + resourceUri, + { description: "Customer Segmentation Explorer UI" }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + + return { + contents: [ + { + uri: resourceUri, + mimeType: "text/html;profile=mcp-app", + text: html, + }, + ], + }; + }, + ); +} + +const app = express(); +app.use(cors()); +app.use(express.json()); + +app.post("/mcp", async (req: Request, res: Response) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { + transport.close(); + }); + + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } +}); + +const httpServer = app.listen(PORT, () => { + console.log( + `Customer Segmentation 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/customer-segmentation-server/src/data-generator.ts b/examples/customer-segmentation-server/src/data-generator.ts new file mode 100644 index 00000000..10cfd062 --- /dev/null +++ b/examples/customer-segmentation-server/src/data-generator.ts @@ -0,0 +1,231 @@ +import type { Customer, SegmentSummary, SegmentName } from "./types.ts"; +import { SEGMENT_COLORS, SEGMENTS } from "./types.ts"; + +// Company name generation +const PREFIXES = [ + "Apex", + "Nova", + "Prime", + "Vertex", + "Atlas", + "Quantum", + "Summit", + "Nexus", + "Titan", + "Pinnacle", + "Zenith", + "Vanguard", + "Horizon", + "Stellar", + "Onyx", + "Cobalt", + "Vector", + "Pulse", + "Forge", + "Spark", +]; + +const CORES = [ + "Tech", + "Data", + "Cloud", + "Logic", + "Sync", + "Flow", + "Core", + "Net", + "Soft", + "Wave", + "Link", + "Mind", + "Byte", + "Grid", + "Hub", +]; + +const SUFFIXES = [ + "Corp", + "Inc", + "Solutions", + "Systems", + "Labs", + "Group", + "Industries", + "Dynamics", + "Partners", + "Ventures", + "Global", + "Digital", +]; + +// Cluster centers for each segment +interface ClusterCenter { + annualRevenue: { min: number; max: number }; + employeeCount: { min: number; max: number }; + accountAge: { min: number; max: number }; + engagementScore: { min: number; max: number }; + supportTickets: { min: number; max: number }; + nps: { min: number; max: number }; +} + +const CLUSTER_CENTERS: Record = { + Enterprise: { + annualRevenue: { min: 2_000_000, max: 10_000_000 }, + employeeCount: { min: 500, max: 5000 }, + accountAge: { min: 60, max: 120 }, + engagementScore: { min: 70, max: 95 }, + supportTickets: { min: 5, max: 20 }, + nps: { min: 40, max: 80 }, + }, + "Mid-Market": { + annualRevenue: { min: 500_000, max: 2_000_000 }, + employeeCount: { min: 100, max: 500 }, + accountAge: { min: 36, max: 84 }, + engagementScore: { min: 60, max: 85 }, + supportTickets: { min: 10, max: 30 }, + nps: { min: 20, max: 60 }, + }, + SMB: { + annualRevenue: { min: 50_000, max: 500_000 }, + employeeCount: { min: 10, max: 100 }, + accountAge: { min: 12, max: 48 }, + engagementScore: { min: 40, max: 70 }, + supportTickets: { min: 15, max: 40 }, + nps: { min: 0, max: 40 }, + }, + Startup: { + annualRevenue: { min: 10_000, max: 200_000 }, + employeeCount: { min: 1, max: 50 }, + accountAge: { min: 1, max: 24 }, + engagementScore: { min: 50, max: 90 }, + supportTickets: { min: 5, max: 25 }, + nps: { min: 10, max: 70 }, + }, +}; + +// Segment distribution weights +const SEGMENT_WEIGHTS: Record = { + Enterprise: 0.15, + "Mid-Market": 0.25, + SMB: 0.35, + Startup: 0.25, +}; + +// Box-Muller transform for Gaussian random numbers +function gaussianRandom(mean: number, stdDev: number): number { + const u1 = Math.random(); + const u2 = Math.random(); + const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); + return z0 * stdDev + mean; +} + +// Generate a value within range with Gaussian distribution centered in range +function generateClusteredValue(min: number, max: number): number { + const mean = (min + max) / 2; + const stdDev = (max - min) / 4; // 95% of values within range + const value = gaussianRandom(mean, stdDev); + return Math.max(min * 0.8, Math.min(max * 1.2, value)); // Allow slight overflow +} + +// Generate unique company name +function generateCompanyName(usedNames: Set): string { + let attempts = 0; + while (attempts < 100) { + const prefix = PREFIXES[Math.floor(Math.random() * PREFIXES.length)]; + const core = CORES[Math.floor(Math.random() * CORES.length)]; + const suffix = SUFFIXES[Math.floor(Math.random() * SUFFIXES.length)]; + const name = `${prefix} ${core} ${suffix}`; + if (!usedNames.has(name)) { + usedNames.add(name); + return name; + } + attempts++; + } + // Fallback: add a number + const prefix = PREFIXES[Math.floor(Math.random() * PREFIXES.length)]; + const core = CORES[Math.floor(Math.random() * CORES.length)]; + const num = Math.floor(Math.random() * 1000); + return `${prefix} ${core} ${num}`; +} + +// Select segment based on weights +function selectSegment(): SegmentName { + const rand = Math.random(); + let cumulative = 0; + for (const segment of SEGMENTS) { + cumulative += SEGMENT_WEIGHTS[segment]; + if (rand < cumulative) { + return segment; + } + } + return "SMB"; +} + +// Generate a single customer +function generateCustomer(id: number, usedNames: Set): Customer { + const segment = selectSegment(); + const center = CLUSTER_CENTERS[segment]; + + return { + id: `cust-${id.toString().padStart(4, "0")}`, + name: generateCompanyName(usedNames), + segment, + annualRevenue: Math.round( + generateClusteredValue( + center.annualRevenue.min, + center.annualRevenue.max, + ), + ), + employeeCount: Math.round( + generateClusteredValue( + center.employeeCount.min, + center.employeeCount.max, + ), + ), + accountAge: Math.round( + generateClusteredValue(center.accountAge.min, center.accountAge.max), + ), + engagementScore: Math.round( + generateClusteredValue( + center.engagementScore.min, + center.engagementScore.max, + ), + ), + supportTickets: Math.round( + generateClusteredValue( + center.supportTickets.min, + center.supportTickets.max, + ), + ), + nps: Math.round(generateClusteredValue(center.nps.min, center.nps.max)), + }; +} + +// Generate all customers +export function generateCustomers(count: number = 250): Customer[] { + const usedNames = new Set(); + const customers: Customer[] = []; + + for (let i = 0; i < count; i++) { + customers.push(generateCustomer(i + 1, usedNames)); + } + + return customers; +} + +// Generate segment summaries from customers +export function generateSegmentSummaries( + customers: Customer[], +): SegmentSummary[] { + const counts = new Map(); + + for (const customer of customers) { + counts.set(customer.segment, (counts.get(customer.segment) || 0) + 1); + } + + return SEGMENTS.map((segment) => ({ + name: segment, + count: counts.get(segment) || 0, + color: SEGMENT_COLORS[segment], + })); +} diff --git a/examples/customer-segmentation-server/src/global.css b/examples/customer-segmentation-server/src/global.css new file mode 100644 index 00000000..97cda440 --- /dev/null +++ b/examples/customer-segmentation-server/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/customer-segmentation-server/src/mcp-app.css b/examples/customer-segmentation-server/src/mcp-app.css new file mode 100644 index 00000000..0158c341 --- /dev/null +++ b/examples/customer-segmentation-server/src/mcp-app.css @@ -0,0 +1,224 @@ +:root { + --color-bg: #ffffff; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-card-bg: #f9fafb; + --color-border: #e5e7eb; + --color-enterprise: #1e40af; + --color-midmarket: #0d9488; + --color-smb: #059669; + --color-startup: #6366f1; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #111827; + --color-text: #f9fafb; + --color-text-muted: #9ca3af; + --color-primary: #3b82f6; + --color-primary-hover: #60a5fa; + --color-card-bg: #1f2937; + --color-border: #374151; + --color-enterprise: #3b82f6; + --color-midmarket: #14b8a6; + --color-smb: #10b981; + --color-startup: #818cf8; + } +} + +html, body { + margin: 0; + padding: 0; + background: var(--color-bg); + color: var(--color-text); + overflow: hidden; +} + +.main { + width: 600px; + height: 600px; + margin: 0 auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; +} + +/* Header - ~40px */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + height: 36px; + flex-shrink: 0; +} + +.title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + flex-shrink: 0; +} + +.header-controls { + display: flex; + align-items: center; + gap: 8px; +} + +/* Controls section - ~36px */ +.controls-section { + display: flex; + align-items: center; + gap: 16px; + height: 32px; + flex-shrink: 0; +} + +.select-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-muted); +} + +.select { + padding: 4px 8px; + font-size: 0.8125rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-card-bg); + color: var(--color-text); + cursor: pointer; +} + +.select:focus { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + +/* Chart section - ~420px */ +.chart-section { + flex: 1; + min-height: 0; + background: var(--color-card-bg); + border-radius: 8px; + padding: 8px; + border: 1px solid var(--color-border); +} + +.chart-container { + position: relative; + width: 100%; + height: 100%; +} + +/* Legend section - ~36px */ +.legend-section { + height: 32px; + flex-shrink: 0; +} + +.legend { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + height: 100%; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + cursor: pointer; + padding: 4px 10px; + border-radius: 16px; + border: 1px solid var(--color-border); + background: var(--color-card-bg); + transition: all 0.15s ease; +} + +.legend-item:hover { + border-color: var(--color-text-muted); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.legend-item.hidden { + opacity: 0.5; + background: transparent; +} + +.legend-item.hidden .legend-label { + text-decoration: line-through; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.legend-label { + font-weight: 500; +} + +.legend-count { + color: var(--color-text-muted); +} + +/* Detail section - ~44px */ +.detail-section { + height: 40px; + flex-shrink: 0; +} + +.detail-panel { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + height: 100%; + background: var(--color-card-bg); + border-radius: 6px; + padding: 0 12px; + border: 1px solid var(--color-border); + font-size: 0.8125rem; +} + +.detail-placeholder { + color: var(--color-text-muted); +} + +.detail-name { + font-weight: 600; +} + +.detail-segment { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + color: white; +} + +.detail-segment.enterprise { background: var(--color-enterprise); } +.detail-segment.mid-market { background: var(--color-midmarket); } +.detail-segment.smb { background: var(--color-smb); } +.detail-segment.startup { background: var(--color-startup); } + +.detail-metric { + color: var(--color-text-muted); +} + +.detail-metric strong { + color: var(--color-text); + font-weight: 600; +} diff --git a/examples/customer-segmentation-server/src/mcp-app.ts b/examples/customer-segmentation-server/src/mcp-app.ts new file mode 100644 index 00000000..3d9b2f7f --- /dev/null +++ b/examples/customer-segmentation-server/src/mcp-app.ts @@ -0,0 +1,401 @@ +/** + * @file Customer Segmentation Explorer - interactive scatter/bubble visualization + */ +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import { Chart, registerables } from "chart.js"; +import "./global.css"; +import "./mcp-app.css"; +import type { Customer, SegmentSummary, MetricName } from "./types.ts"; +import { SEGMENT_COLORS, METRIC_LABELS } from "./types.ts"; + +// Register Chart.js components +Chart.register(...registerables); + +const log = { + info: console.log.bind(console, "[APP]"), + error: console.error.bind(console, "[APP]"), +}; + +// DOM element references +const xAxisSelect = document.getElementById("x-axis") as HTMLSelectElement; +const yAxisSelect = document.getElementById("y-axis") as HTMLSelectElement; +const sizeMetricSelect = document.getElementById( + "size-metric", +) as HTMLSelectElement; +const chartCanvas = document.getElementById( + "scatter-chart", +) as HTMLCanvasElement; +const legendContainer = document.getElementById("legend")!; +const detailPanel = document.getElementById("detail-panel")!; + +// App state +interface AppState { + customers: Customer[]; + segments: SegmentSummary[]; + chart: Chart | null; + xAxis: MetricName; + yAxis: MetricName; + sizeMetric: string; + hiddenSegments: Set; + selectedCustomer: Customer | null; +} + +const state: AppState = { + customers: [], + segments: [], + chart: null, + xAxis: "annualRevenue", + yAxis: "engagementScore", + sizeMetric: "off", + hiddenSegments: new Set(), + selectedCustomer: null, +}; + +// Format numbers for display +function formatValue(value: number, metric: MetricName): string { + switch (metric) { + case "annualRevenue": + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(1)}M`; + } + return `$${(value / 1_000).toFixed(0)}K`; + case "employeeCount": + return value.toLocaleString(); + case "accountAge": + return `${value}mo`; + case "engagementScore": + return `${value}`; + case "supportTickets": + return `${value}`; + case "nps": + return value >= 0 ? `+${value}` : `${value}`; + default: + return `${value}`; + } +} + +// Get min/max for a metric across all customers +function getMetricRange( + customers: Customer[], + metric: MetricName, +): { min: number; max: number } { + const values = customers.map((c) => c[metric] as number); + return { + min: Math.min(...values), + max: Math.max(...values), + }; +} + +// Normalize value to bubble radius (6-30px range) +function normalizeToRadius(value: number, min: number, max: number): number { + if (max === min) return 12; + const normalized = (value - min) / (max - min); + return 6 + normalized * 24; +} + +// Get filtered customers based on current state +function getFilteredCustomers(): Customer[] { + return state.customers.filter((c) => !state.hiddenSegments.has(c.segment)); +} + +// Build chart datasets from customers +function buildDatasets(): Chart["data"]["datasets"] { + const customers = getFilteredCustomers(); + const segments = [...new Set(customers.map((c) => c.segment))]; + + // Calculate size range if size metric is enabled + let sizeRange: { min: number; max: number } | null = null; + if (state.sizeMetric !== "off") { + sizeRange = getMetricRange(state.customers, state.sizeMetric as MetricName); + } + + return segments.map((segment) => { + const segmentCustomers = customers.filter((c) => c.segment === segment); + const color = SEGMENT_COLORS[segment] || "#888888"; + + return { + label: segment, + data: segmentCustomers.map((c) => ({ + x: c[state.xAxis] as number, + y: c[state.yAxis] as number, + r: + sizeRange && state.sizeMetric !== "off" + ? normalizeToRadius( + c[state.sizeMetric as MetricName] as number, + sizeRange.min, + sizeRange.max, + ) + : 8, + customer: c, + })), + backgroundColor: color + "aa", + borderColor: color, + borderWidth: 1, + hoverBackgroundColor: color, + hoverBorderColor: "#ffffff", + hoverBorderWidth: 2, + }; + }); +} + +// Initialize Chart.js +function initChart(): Chart { + const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; + const textColor = isDarkMode ? "#9ca3af" : "#6b7280"; + const gridColor = isDarkMode ? "#374151" : "#e5e7eb"; + + return new Chart(chartCanvas, { + type: "bubble", + data: { + datasets: buildDatasets(), + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 300, + }, + interaction: { + intersect: true, + mode: "nearest", + }, + plugins: { + legend: { + display: false, // Using custom legend + }, + tooltip: { + enabled: true, + callbacks: { + label: (context) => { + const point = context.raw as { customer: Customer }; + const c = point.customer; + return [ + c.name, + `${METRIC_LABELS[state.xAxis]}: ${formatValue(c[state.xAxis] as number, state.xAxis)}`, + `${METRIC_LABELS[state.yAxis]}: ${formatValue(c[state.yAxis] as number, state.yAxis)}`, + ]; + }, + }, + }, + }, + scales: { + x: { + title: { + display: true, + text: METRIC_LABELS[state.xAxis], + color: textColor, + font: { size: 11, weight: "bold" }, + }, + ticks: { + color: textColor, + font: { size: 10 }, + callback: (value) => formatValue(value as number, state.xAxis), + }, + grid: { + color: gridColor, + }, + }, + y: { + title: { + display: true, + text: METRIC_LABELS[state.yAxis], + color: textColor, + font: { size: 11, weight: "bold" }, + }, + ticks: { + color: textColor, + font: { size: 10 }, + callback: (value) => formatValue(value as number, state.yAxis), + }, + grid: { + color: gridColor, + }, + }, + }, + onClick: (_event, elements) => { + if (elements.length > 0) { + const element = elements[0]; + const dataset = state.chart!.data.datasets[element.datasetIndex]; + const point = dataset.data[element.index] as unknown as { + customer: Customer; + }; + state.selectedCustomer = point.customer; + updateDetailPanel(point.customer); + } + }, + onHover: (_event, elements) => { + if (elements.length > 0 && !state.selectedCustomer) { + const element = elements[0]; + const dataset = state.chart!.data.datasets[element.datasetIndex]; + const point = dataset.data[element.index] as unknown as { + customer: Customer; + }; + updateDetailPanel(point.customer); + } else if (elements.length === 0 && !state.selectedCustomer) { + resetDetailPanel(); + } + }, + }, + }); +} + +// Update chart with new data +function updateChart(): void { + if (!state.chart) return; + + const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; + const textColor = isDarkMode ? "#9ca3af" : "#6b7280"; + + state.chart.data.datasets = buildDatasets(); + + // Update axis titles and formatters (using type assertions for Chart.js scale options) + const scales = state.chart.options.scales as { + x: { + title: { text: string; color: string }; + ticks: { callback: (value: number) => string }; + }; + y: { + title: { text: string; color: string }; + ticks: { callback: (value: number) => string }; + }; + }; + + scales.x.title.text = METRIC_LABELS[state.xAxis]; + scales.y.title.text = METRIC_LABELS[state.yAxis]; + scales.x.title.color = textColor; + scales.y.title.color = textColor; + scales.x.ticks.callback = (value: number) => formatValue(value, state.xAxis); + scales.y.ticks.callback = (value: number) => formatValue(value, state.yAxis); + + state.chart.update(); +} + +// Render custom legend +function renderLegend(): void { + // Count customers per segment + const counts = new Map(); + for (const c of state.customers) { + counts.set(c.segment, (counts.get(c.segment) || 0) + 1); + } + + legendContainer.innerHTML = state.segments + .map((seg) => { + const count = counts.get(seg.name) || 0; + const isHidden = state.hiddenSegments.has(seg.name); + return ` +
+ + ${seg.name} + (${count}) +
+ `; + }) + .join(""); + + // Add click handlers + legendContainer.querySelectorAll(".legend-item").forEach((item) => { + item.addEventListener("click", () => { + const segment = item.getAttribute("data-segment")!; + if (state.hiddenSegments.has(segment)) { + state.hiddenSegments.delete(segment); + } else { + state.hiddenSegments.add(segment); + } + renderLegend(); + updateChart(); + }); + }); +} + +// Update detail panel with customer info +function updateDetailPanel(customer: Customer): void { + const segmentClass = customer.segment.toLowerCase().replace("-", "-"); + detailPanel.innerHTML = ` + ${customer.name} + ${customer.segment} + ${formatValue(customer.annualRevenue, "annualRevenue")} rev + ${customer.engagementScore} engagement + ${customer.nps >= 0 ? "+" : ""}${customer.nps} NPS + `; +} + +// Reset detail panel to placeholder +function resetDetailPanel(): void { + detailPanel.innerHTML = + 'Hover over a point to see details'; +} + +// Create app instance +const app = new App({ name: "Customer Segmentation", version: "1.0.0" }); + +// Fetch data from server +async function fetchData(): Promise { + try { + const result = await app.callServerTool({ + name: "get-customer-data", + arguments: {}, + }); + + const data = result.structuredContent as unknown as { + customers: Customer[]; + segments: SegmentSummary[]; + }; + + state.customers = data.customers; + state.segments = data.segments; + + // Initialize or update chart + if (!state.chart) { + state.chart = initChart(); + } else { + updateChart(); + } + + renderLegend(); + log.info(`Loaded ${data.customers.length} customers`); + } catch (error) { + log.error("Failed to fetch data:", error); + } +} + +// Event handlers +xAxisSelect.addEventListener("change", () => { + state.xAxis = xAxisSelect.value as MetricName; + updateChart(); +}); + +yAxisSelect.addEventListener("change", () => { + state.yAxis = yAxisSelect.value as MetricName; + updateChart(); +}); + +sizeMetricSelect.addEventListener("change", () => { + state.sizeMetric = sizeMetricSelect.value; + updateChart(); +}); + +// Clear selection when clicking outside chart +document.addEventListener("click", (e) => { + if (!(e.target as HTMLElement).closest(".chart-section")) { + state.selectedCustomer = null; + resetDetailPanel(); + } +}); + +// Handle theme changes +window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", () => { + if (state.chart) { + state.chart.destroy(); + state.chart = initChart(); + } + }); + +// Register handlers and connect +app.onerror = log.error; + +app.connect(new PostMessageTransport(window.parent)); + +// Fetch data after connection +setTimeout(fetchData, 100); diff --git a/examples/customer-segmentation-server/src/types.ts b/examples/customer-segmentation-server/src/types.ts new file mode 100644 index 00000000..daaa56c4 --- /dev/null +++ b/examples/customer-segmentation-server/src/types.ts @@ -0,0 +1,46 @@ +export interface Customer { + id: string; + name: string; + segment: string; + annualRevenue: number; + employeeCount: number; + accountAge: number; + engagementScore: number; + supportTickets: number; + nps: number; +} + +export interface SegmentSummary { + name: string; + count: number; + color: string; +} + +export const SEGMENT_COLORS: Record = { + Enterprise: "#1e40af", + "Mid-Market": "#0d9488", + SMB: "#059669", + Startup: "#6366f1", +}; + +export const SEGMENTS = ["Enterprise", "Mid-Market", "SMB", "Startup"] as const; +export type SegmentName = (typeof SEGMENTS)[number]; + +export const METRIC_LABELS: Record = { + annualRevenue: "Annual Revenue", + employeeCount: "Employees", + accountAge: "Account Age (mo)", + engagementScore: "Engagement", + supportTickets: "Support Tickets", + nps: "NPS", +}; + +export const METRICS = [ + "annualRevenue", + "employeeCount", + "accountAge", + "engagementScore", + "supportTickets", + "nps", +] as const; +export type MetricName = (typeof METRICS)[number]; diff --git a/examples/customer-segmentation-server/tsconfig.json b/examples/customer-segmentation-server/tsconfig.json new file mode 100644 index 00000000..535267b2 --- /dev/null +++ b/examples/customer-segmentation-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/customer-segmentation-server/vite.config.ts b/examples/customer-segmentation-server/vite.config.ts new file mode 100644 index 00000000..6ff6d997 --- /dev/null +++ b/examples/customer-segmentation-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/examples/scenario-modeler-server/README.md b/examples/scenario-modeler-server/README.md new file mode 100644 index 00000000..4625169d --- /dev/null +++ b/examples/scenario-modeler-server/README.md @@ -0,0 +1,50 @@ +# Example: SaaS Scenario Modeler + +A React-based demo MCP App that lets users adjust SaaS business parameters and see real-time 12-month projections of revenue, costs, and profitability with comparison against pre-built scenario templates. + +## Features + +- **Interactive Parameters**: 5 sliders for Starting MRR, Growth Rate, Churn Rate, Gross Margin, and Fixed Costs +- **12-Month Projections**: Line chart showing MRR, Gross Profit, and Net Profit over time +- **Scenario Templates**: 5 pre-built business strategies (Bootstrapped, VC Rocketship, Cash Cow, Turnaround, Efficient Growth) +- **Template Comparison**: Overlay dashed lines to compare your scenario against any template +- **Summary Metrics**: Key metrics including Ending MRR, Total Revenue, Total Profit, and break-even month +- **Theme Support**: Adapts to light/dark mode preferences + +## 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-scenario-data` tool that returns: + +- 5 pre-built scenario templates with parameters, projections, and summaries +- Default input values for the sliders +- Optionally computes custom projections when `customInputs` are provided + +The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`. + +### App (`src/`) + +- Built with React for reactive slider updates and derived state management +- Uses Chart.js for the line chart visualization +- All projection calculations run client-side for instant slider feedback +- Components: `SliderRow`, `MetricCard`, `ProjectionChart` +- Template comparison shown as dashed overlay lines on the chart diff --git a/examples/scenario-modeler-server/mcp-app.html b/examples/scenario-modeler-server/mcp-app.html new file mode 100644 index 00000000..60de7106 --- /dev/null +++ b/examples/scenario-modeler-server/mcp-app.html @@ -0,0 +1,12 @@ + + + + + + SaaS Scenario Modeler + + +
+ + + diff --git a/examples/scenario-modeler-server/package.json b/examples/scenario-modeler-server/package.json new file mode 100644 index 00000000..256626c9 --- /dev/null +++ b/examples/scenario-modeler-server/package.json @@ -0,0 +1,35 @@ +{ + "name": "scenario-modeler-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", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "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/scenario-modeler-server/server.ts b/examples/scenario-modeler-server/server.ts new file mode 100644 index 00000000..170d754e --- /dev/null +++ b/examples/scenario-modeler-server/server.ts @@ -0,0 +1,406 @@ +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 ScenarioInputsSchema = z.object({ + startingMRR: z.number(), + monthlyGrowthRate: z.number(), + monthlyChurnRate: z.number(), + grossMargin: z.number(), + fixedCosts: z.number(), +}); + +const MonthlyProjectionSchema = z.object({ + month: z.number(), + mrr: z.number(), + grossProfit: z.number(), + netProfit: z.number(), + cumulativeRevenue: z.number(), +}); + +const ScenarioSummarySchema = z.object({ + endingMRR: z.number(), + arr: z.number(), + totalRevenue: z.number(), + totalProfit: z.number(), + mrrGrowthPct: z.number(), + avgMargin: z.number(), + breakEvenMonth: z.number().nullable(), +}); + +const ScenarioTemplateSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + icon: z.string(), + parameters: ScenarioInputsSchema, + projections: z.array(MonthlyProjectionSchema), + summary: ScenarioSummarySchema, + keyInsight: z.string(), +}); + +const GetScenarioDataInputSchema = z.object({ + customInputs: ScenarioInputsSchema.optional().describe( + "Custom scenario parameters to compute projections for", + ), +}); + +const GetScenarioDataOutputSchema = z.object({ + templates: z.array(ScenarioTemplateSchema), + defaultInputs: ScenarioInputsSchema, + customProjections: z.array(MonthlyProjectionSchema).optional(), + customSummary: ScenarioSummarySchema.optional(), +}); + +// Types derived from schemas +type ScenarioInputs = z.infer; +type MonthlyProjection = z.infer; +type ScenarioSummary = z.infer; +type ScenarioTemplate = z.infer; + +// ============================================================================ +// Calculations +// ============================================================================ + +function calculateProjections(inputs: ScenarioInputs): MonthlyProjection[] { + const { + startingMRR, + monthlyGrowthRate, + monthlyChurnRate, + grossMargin, + fixedCosts, + } = inputs; + + const netGrowthRate = (monthlyGrowthRate - monthlyChurnRate) / 100; + const projections: MonthlyProjection[] = []; + let cumulativeRevenue = 0; + + for (let month = 1; month <= 12; month++) { + const mrr = startingMRR * Math.pow(1 + netGrowthRate, month); + const grossProfit = mrr * (grossMargin / 100); + const netProfit = grossProfit - fixedCosts; + cumulativeRevenue += mrr; + + projections.push({ + month, + mrr, + grossProfit, + netProfit, + cumulativeRevenue, + }); + } + + return projections; +} + +function calculateSummary( + projections: MonthlyProjection[], + inputs: ScenarioInputs, +): ScenarioSummary { + const endingMRR = projections[11].mrr; + const arr = endingMRR * 12; + const totalRevenue = projections.reduce((sum, p) => sum + p.mrr, 0); + const totalProfit = projections.reduce((sum, p) => sum + p.netProfit, 0); + const mrrGrowthPct = + ((endingMRR - inputs.startingMRR) / inputs.startingMRR) * 100; + const avgMargin = (totalProfit / totalRevenue) * 100; + + const breakEvenProjection = projections.find((p) => p.netProfit >= 0); + const breakEvenMonth = breakEvenProjection?.month ?? null; + + return { + endingMRR, + arr, + totalRevenue, + totalProfit, + mrrGrowthPct, + avgMargin, + breakEvenMonth, + }; +} + +function calculateScenario(inputs: ScenarioInputs) { + const projections = calculateProjections(inputs); + const summary = calculateSummary(projections, inputs); + return { projections, summary }; +} + +function buildTemplate( + id: string, + name: string, + description: string, + icon: string, + parameters: ScenarioInputs, + keyInsight: string, +): ScenarioTemplate { + const { projections, summary } = calculateScenario(parameters); + return { + id, + name, + description, + icon, + parameters, + projections, + summary, + keyInsight, + }; +} + +// ============================================================================ +// Pre-defined Templates +// ============================================================================ + +const SCENARIO_TEMPLATES: ScenarioTemplate[] = [ + buildTemplate( + "bootstrapped", + "Bootstrapped Growth", + "Low burn, steady growth, path to profitability", + "🌱", + { + startingMRR: 30000, + monthlyGrowthRate: 4, + monthlyChurnRate: 2, + grossMargin: 85, + fixedCosts: 20000, + }, + "Profitable by month 1, but slower scale", + ), + buildTemplate( + "vc-rocketship", + "VC Rocketship", + "High burn, explosive growth, raise more later", + "🚀", + { + startingMRR: 100000, + monthlyGrowthRate: 15, + monthlyChurnRate: 5, + grossMargin: 70, + fixedCosts: 150000, + }, + "Loses money early but ends at 3x MRR", + ), + buildTemplate( + "cash-cow", + "Cash Cow", + "Mature product, high margin, stable revenue", + "🐄", + { + startingMRR: 80000, + monthlyGrowthRate: 2, + monthlyChurnRate: 1, + grossMargin: 90, + fixedCosts: 40000, + }, + "Consistent profitability, low risk", + ), + buildTemplate( + "turnaround", + "Turnaround", + "Fighting churn, rebuilding product-market fit", + "🔄", + { + startingMRR: 60000, + monthlyGrowthRate: 6, + monthlyChurnRate: 8, + grossMargin: 75, + fixedCosts: 50000, + }, + "Negative net growth requires urgent action", + ), + buildTemplate( + "efficient-growth", + "Efficient Growth", + "Balanced approach with sustainable economics", + "⚖️", + { + startingMRR: 50000, + monthlyGrowthRate: 8, + monthlyChurnRate: 3, + grossMargin: 80, + fixedCosts: 35000, + }, + "Good growth with path to profitability", + ), +]; + +const DEFAULT_INPUTS: ScenarioInputs = { + startingMRR: 50000, + monthlyGrowthRate: 5, + monthlyChurnRate: 3, + grossMargin: 80, + fixedCosts: 30000, +}; + +// ============================================================================ +// Formatters for text output +// ============================================================================ + +function formatCurrency(value: number): string { + const absValue = Math.abs(value); + const sign = value < 0 ? "-" : ""; + if (absValue >= 1_000_000) { + return `${sign}$${(absValue / 1_000_000).toFixed(2)}M`; + } + if (absValue >= 1_000) { + return `${sign}$${(absValue / 1_000).toFixed(1)}K`; + } + return `${sign}$${Math.round(absValue)}`; +} + +function formatScenarioSummary( + summary: ScenarioSummary, + label: string, +): string { + return [ + `${label}:`, + ` Ending MRR: ${formatCurrency(summary.endingMRR)}`, + ` ARR: ${formatCurrency(summary.arr)}`, + ` Total Revenue: ${formatCurrency(summary.totalRevenue)}`, + ` Total Profit: ${formatCurrency(summary.totalProfit)}`, + ` MRR Growth: ${summary.mrrGrowthPct.toFixed(1)}%`, + ` Break-even: ${summary.breakEvenMonth ? `Month ${summary.breakEvenMonth}` : "Not achieved"}`, + ].join("\n"); +} + +// ============================================================================ +// MCP Server +// ============================================================================ + +const server = new McpServer({ + name: "SaaS Scenario Modeler", + version: "1.0.0", +}); + +// Register tool and resource +{ + const resourceUri = "ui://scenario-modeler/mcp-app.html"; + + server.registerTool( + "get-scenario-data", + { + title: "Get Scenario Data", + description: + "Returns SaaS scenario templates and optionally computes custom projections for given inputs", + inputSchema: GetScenarioDataInputSchema.shape, + outputSchema: GetScenarioDataOutputSchema.shape, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async (args: { + customInputs?: ScenarioInputs; + }): Promise => { + const customScenario = args.customInputs + ? calculateScenario(args.customInputs) + : undefined; + + const text = [ + "SaaS Scenario Modeler", + "=".repeat(40), + "", + "Available Templates:", + ...SCENARIO_TEMPLATES.map( + (t) => ` ${t.icon} ${t.name}: ${t.description}`, + ), + "", + customScenario + ? formatScenarioSummary(customScenario.summary, "Custom Scenario") + : "Use customInputs parameter to compute projections for a specific scenario.", + ].join("\n"); + + return { + content: [{ type: "text", text }], + structuredContent: { + templates: SCENARIO_TEMPLATES, + defaultInputs: DEFAULT_INPUTS, + customProjections: customScenario?.projections, + customSummary: customScenario?.summary, + }, + }; + }, + ); + + server.registerResource( + resourceUri, + resourceUri, + { description: "SaaS Scenario Modeler UI" }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { + uri: resourceUri, + mimeType: "text/html;profile=mcp-app", + 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( + `SaaS Scenario Modeler 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/scenario-modeler-server/src/components/MetricCard.tsx b/examples/scenario-modeler-server/src/components/MetricCard.tsx new file mode 100644 index 00000000..8ab2b071 --- /dev/null +++ b/examples/scenario-modeler-server/src/components/MetricCard.tsx @@ -0,0 +1,18 @@ +interface MetricCardProps { + label: string; + value: string; + variant?: "default" | "positive" | "negative"; +} + +export function MetricCard({ + label, + value, + variant = "default", +}: MetricCardProps) { + return ( +
+ {value} + {label} +
+ ); +} diff --git a/examples/scenario-modeler-server/src/components/ProjectionChart.tsx b/examples/scenario-modeler-server/src/components/ProjectionChart.tsx new file mode 100644 index 00000000..1a10d2d5 --- /dev/null +++ b/examples/scenario-modeler-server/src/components/ProjectionChart.tsx @@ -0,0 +1,206 @@ +import { useRef, useEffect } from "react"; +import { Chart, registerables } from "chart.js"; +import { useTheme } from "../hooks/useTheme.ts"; +import type { MonthlyProjection } from "../types.ts"; + +Chart.register(...registerables); + +interface ProjectionChartProps { + userProjections: MonthlyProjection[]; + templateProjections: MonthlyProjection[] | null; + templateName?: string; +} + +export function ProjectionChart({ + userProjections, + templateProjections, + templateName, +}: ProjectionChartProps) { + const canvasRef = useRef(null); + const chartRef = useRef(null); + const theme = useTheme(); + + // Create chart on mount, rebuild on theme change + useEffect(() => { + if (!canvasRef.current) return; + + const textColor = theme === "dark" ? "#9ca3af" : "#6b7280"; + const gridColor = theme === "dark" ? "#374151" : "#e5e7eb"; + + chartRef.current = new Chart(canvasRef.current, { + type: "line", + data: { + labels: Array.from({ length: 12 }, (_, i) => `M${i + 1}`), + datasets: [ + // User scenario (solid lines) + { + label: "MRR", + borderColor: "#3b82f6", + backgroundColor: "rgba(59, 130, 246, 0.1)", + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "Gross Profit", + borderColor: "#10b981", + backgroundColor: "rgba(16, 185, 129, 0.1)", + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "Net Profit", + borderColor: "#f59e0b", + backgroundColor: "rgba(245, 158, 11, 0.1)", + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + }, + // Template comparison (dashed lines) + { + label: "Template MRR", + borderColor: "#3b82f6", + borderDash: [5, 5], + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 1.5, + hidden: true, + }, + { + label: "Template Gross", + borderColor: "#10b981", + borderDash: [5, 5], + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 1.5, + hidden: true, + }, + { + label: "Template Net", + borderColor: "#f59e0b", + borderDash: [5, 5], + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 1.5, + hidden: true, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: "index", + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: theme === "dark" ? "#1f2937" : "#ffffff", + titleColor: theme === "dark" ? "#f9fafb" : "#111827", + bodyColor: theme === "dark" ? "#9ca3af" : "#6b7280", + borderColor: gridColor, + borderWidth: 1, + callbacks: { + label: (context) => { + const value = context.parsed.y; + if (value === null) return ""; + const formatted = + Math.abs(value) >= 1000 + ? `$${(value / 1000).toFixed(1)}K` + : `$${value.toFixed(0)}`; + return `${context.dataset.label}: ${formatted}`; + }, + }, + }, + }, + scales: { + y: { + grid: { color: gridColor }, + ticks: { + color: textColor, + callback: (value) => { + const num = Number(value); + if (Math.abs(num) >= 1000) { + return `$${(num / 1000).toFixed(0)}K`; + } + return `$${num}`; + }, + }, + }, + x: { + grid: { display: false }, + ticks: { color: textColor }, + }, + }, + }, + }); + + return () => chartRef.current?.destroy(); + }, [theme]); + + // Update data when projections change + useEffect(() => { + if (!chartRef.current) return; + + const chart = chartRef.current; + chart.data.datasets[0].data = userProjections.map((p) => p.mrr); + chart.data.datasets[1].data = userProjections.map((p) => p.grossProfit); + chart.data.datasets[2].data = userProjections.map((p) => p.netProfit); + + if (templateProjections) { + chart.data.datasets[3].data = templateProjections.map((p) => p.mrr); + chart.data.datasets[4].data = templateProjections.map( + (p) => p.grossProfit, + ); + chart.data.datasets[5].data = templateProjections.map((p) => p.netProfit); + chart.data.datasets[3].hidden = false; + chart.data.datasets[4].hidden = false; + chart.data.datasets[5].hidden = false; + } else { + chart.data.datasets[3].hidden = true; + chart.data.datasets[4].hidden = true; + chart.data.datasets[5].hidden = true; + } + + chart.update("none"); // Skip animation for smoother slider updates + }, [userProjections, templateProjections]); + + return ( +
+

12-Month Projection

+
+ +
+
+ + -- MRR + + + -- Gross Profit + + + -- Net Profit + + {templateProjections && ( + + (dashed = {templateName}) + + )} +
+
+ ); +} diff --git a/examples/scenario-modeler-server/src/components/SliderRow.tsx b/examples/scenario-modeler-server/src/components/SliderRow.tsx new file mode 100644 index 00000000..8b25cf15 --- /dev/null +++ b/examples/scenario-modeler-server/src/components/SliderRow.tsx @@ -0,0 +1,35 @@ +interface SliderRowProps { + label: string; + value: number; + min: number; + max: number; + step: number; + format: (v: number) => string; + onChange: (value: number) => void; +} + +export function SliderRow({ + label, + value, + min, + max, + step, + format, + onChange, +}: SliderRowProps) { + return ( +
+ + onChange(Number(e.target.value))} + /> + {format(value)} +
+ ); +} diff --git a/examples/scenario-modeler-server/src/global.css b/examples/scenario-modeler-server/src/global.css new file mode 100644 index 00000000..3599b9a6 --- /dev/null +++ b/examples/scenario-modeler-server/src/global.css @@ -0,0 +1,14 @@ +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/scenario-modeler-server/src/hooks/useTheme.ts b/examples/scenario-modeler-server/src/hooks/useTheme.ts new file mode 100644 index 00000000..fd93c047 --- /dev/null +++ b/examples/scenario-modeler-server/src/hooks/useTheme.ts @@ -0,0 +1,25 @@ +import { useState, useEffect } from "react"; + +export type Theme = "light" | "dark"; + +export function useTheme(): Theme { + const [theme, setTheme] = useState(() => { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + }); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + const handleChange = (e: MediaQueryListEvent) => { + setTheme(e.matches ? "dark" : "light"); + }; + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, []); + + return theme; +} diff --git a/examples/scenario-modeler-server/src/lib/calculations.ts b/examples/scenario-modeler-server/src/lib/calculations.ts new file mode 100644 index 00000000..51a82dd8 --- /dev/null +++ b/examples/scenario-modeler-server/src/lib/calculations.ts @@ -0,0 +1,72 @@ +import type { + ScenarioInputs, + MonthlyProjection, + ScenarioSummary, +} from "../types.ts"; + +export function calculateProjections( + inputs: ScenarioInputs, +): MonthlyProjection[] { + const { + startingMRR, + monthlyGrowthRate, + monthlyChurnRate, + grossMargin, + fixedCosts, + } = inputs; + + // Net growth rate (can be negative if churn > growth) + const netGrowthRate = (monthlyGrowthRate - monthlyChurnRate) / 100; + + const projections: MonthlyProjection[] = []; + let cumulativeRevenue = 0; + + for (let month = 1; month <= 12; month++) { + // MRR with compound growth + const mrr = startingMRR * Math.pow(1 + netGrowthRate, month); + + // Profit calculations + const grossProfit = mrr * (grossMargin / 100); + const netProfit = grossProfit - fixedCosts; + + // Cumulative revenue + cumulativeRevenue += mrr; + + projections.push({ + month, + mrr, + grossProfit, + netProfit, + cumulativeRevenue, + }); + } + + return projections; +} + +export function calculateSummary( + projections: MonthlyProjection[], + inputs: ScenarioInputs, +): ScenarioSummary { + const endingMRR = projections[11].mrr; + const arr = endingMRR * 12; + const totalRevenue = projections.reduce((sum, p) => sum + p.mrr, 0); + const totalProfit = projections.reduce((sum, p) => sum + p.netProfit, 0); + const mrrGrowthPct = + ((endingMRR - inputs.startingMRR) / inputs.startingMRR) * 100; + const avgMargin = (totalProfit / totalRevenue) * 100; + + // Find first month where netProfit >= 0 + const breakEvenProjection = projections.find((p) => p.netProfit >= 0); + const breakEvenMonth = breakEvenProjection?.month ?? null; + + return { + endingMRR, + arr, + totalRevenue, + totalProfit, + mrrGrowthPct, + avgMargin, + breakEvenMonth, + }; +} diff --git a/examples/scenario-modeler-server/src/lib/formatters.ts b/examples/scenario-modeler-server/src/lib/formatters.ts new file mode 100644 index 00000000..8ed96273 --- /dev/null +++ b/examples/scenario-modeler-server/src/lib/formatters.ts @@ -0,0 +1,44 @@ +/** + * Format a number as currency with abbreviated suffixes. + * Examples: $50K, $1.2M, -$30K + */ +export function formatCurrency(value: number): string { + const absValue = Math.abs(value); + const sign = value < 0 ? "-" : ""; + + if (absValue >= 1_000_000) { + const formatted = (absValue / 1_000_000).toFixed( + absValue >= 10_000_000 ? 1 : 2, + ); + // Remove trailing zeros after decimal + const cleaned = formatted.replace(/\.?0+$/, ""); + return `${sign}$${cleaned}M`; + } + + if (absValue >= 1_000) { + const formatted = (absValue / 1_000).toFixed(absValue >= 100_000 ? 0 : 1); + const cleaned = formatted.replace(/\.?0+$/, ""); + return `${sign}$${cleaned}K`; + } + + return `${sign}$${Math.round(absValue)}`; +} + +/** + * Format a number as a percentage. + * Examples: 5%, -2.5%, +12% + */ +export function formatPercent(value: number, showSign = false): string { + const sign = showSign && value > 0 ? "+" : ""; + return `${sign}${value.toFixed(1).replace(/\.0$/, "")}%`; +} + +/** + * Format currency for slider display (always show K suffix for consistency). + */ +export function formatCurrencySlider(value: number): string { + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(0)}K`; + } + return `$${value}`; +} diff --git a/examples/scenario-modeler-server/src/mcp-app.css b/examples/scenario-modeler-server/src/mcp-app.css new file mode 100644 index 00000000..5541668f --- /dev/null +++ b/examples/scenario-modeler-server/src/mcp-app.css @@ -0,0 +1,335 @@ +/* ============================================================================ + CSS Variables for Light/Dark Theme + ============================================================================ */ + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f3f4f6; + --text-primary: #111827; + --text-secondary: #6b7280; + --text-muted: #9ca3af; + --border-color: #e5e7eb; + --accent-blue: #3b82f6; + --accent-green: #10b981; + --accent-amber: #f59e0b; + --accent-red: #ef4444; + --slider-track: #d1d5db; + --slider-thumb: #3b82f6; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg-primary: #111827; + --bg-secondary: #1f2937; + --bg-tertiary: #374151; + --text-primary: #f9fafb; + --text-secondary: #9ca3af; + --text-muted: #6b7280; + --border-color: #374151; + --slider-track: #4b5563; + --slider-thumb: #60a5fa; + } +} + +/* ============================================================================ + Main Layout + ============================================================================ */ + +.main { + width: 600px; + height: 600px; + overflow: hidden; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + flex-direction: column; +} + +/* ============================================================================ + Header (40px) + ============================================================================ */ + +.header { + height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.header-title { + font-size: 14px; + font-weight: 600; + margin: 0; + white-space: nowrap; +} + +.header-controls { + display: flex; + align-items: center; + gap: 6px; +} + +.template-select { + font-size: 12px; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; +} + +.reset-button { + font-size: 12px; + padding: 4px 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; +} + +.reset-button:hover { + background: var(--bg-tertiary); +} + +/* ============================================================================ + Parameters Section (140px) + ============================================================================ */ + +.parameters-section { + padding: 8px 10px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin: 0 0 4px 0; +} + +.slider-row { + display: flex; + align-items: center; + height: 24px; + gap: 8px; +} + +.slider-label { + width: 90px; + font-size: 12px; + color: var(--text-primary); + flex-shrink: 0; +} + +.slider-input { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--slider-track); + border-radius: 2px; + cursor: pointer; +} + +.slider-input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--slider-thumb); + cursor: pointer; +} + +.slider-input::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--slider-thumb); + cursor: pointer; + border: none; +} + +.slider-value { + width: 45px; + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + text-align: right; + flex-shrink: 0; +} + +/* ============================================================================ + Chart Section (230px) + ============================================================================ */ + +.chart-section { + padding: 8px 10px; + border-bottom: 1px solid var(--border-color); + flex: 2; + display: flex; + flex-direction: column; + min-height: 0; +} + +.chart-container { + flex: 1; + position: relative; + min-height: 0; +} + +.chart-container canvas { + width: 100% !important; + height: 100% !important; +} + +.chart-legend { + display: flex; + gap: 16px; + justify-content: center; + font-size: 11px; + color: var(--text-secondary); + padding-top: 4px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.legend-yours { + color: var(--accent-blue); +} + +.legend-template { + color: var(--text-muted); +} + +/* ============================================================================ + Metrics Section (remaining space ~150px) + ============================================================================ */ + +.metrics-section { + flex: 1; + padding: 6px 10px; + display: flex; + flex-direction: column; + min-height: 0; +} + +.metrics-comparison { + display: flex; + gap: 16px; + flex: 1; +} + +.metrics-column { + flex: 1; + display: flex; + flex-direction: column; +} + +.metrics-column h3 { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin: 0 0 4px 0; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 1fr; + gap: 6px; + flex: 1; + min-height: 0; +} + +.metrics-column.template .metrics-grid { + grid-template-columns: repeat(2, 1fr); +} + +.metric-card { + background: var(--bg-secondary); + border-radius: 6px; + padding: 4px 8px; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; +} + +.metric-value { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.metric-card--positive .metric-value { + color: var(--accent-green); +} + +.metric-card--negative .metric-value { + color: var(--accent-red); +} + +.metric-label { + font-size: 10px; + color: var(--text-secondary); + margin-top: 2px; +} + +.metrics-summary { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--border-color); + font-size: 11px; + color: var(--text-secondary); + display: flex; + justify-content: space-between; + flex-shrink: 0; +} + +.summary-item { + display: flex; + gap: 4px; +} + +.summary-value { + font-weight: 600; + color: var(--text-primary); +} + +.summary-value.positive { + color: var(--accent-green); +} + +.summary-value.negative { + color: var(--accent-red); +} + +/* ============================================================================ + Loading State + ============================================================================ */ + +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + font-size: 14px; +} diff --git a/examples/scenario-modeler-server/src/mcp-app.tsx b/examples/scenario-modeler-server/src/mcp-app.tsx new file mode 100644 index 00000000..4f5b1e50 --- /dev/null +++ b/examples/scenario-modeler-server/src/mcp-app.tsx @@ -0,0 +1,336 @@ +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { StrictMode, useState, useMemo, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { SliderRow } from "./components/SliderRow.tsx"; +import { MetricCard } from "./components/MetricCard.tsx"; +import { ProjectionChart } from "./components/ProjectionChart.tsx"; +import { calculateProjections, calculateSummary } from "./lib/calculations.ts"; +import { + formatCurrency, + formatPercent, + formatCurrencySlider, +} from "./lib/formatters.ts"; +import type { + ScenarioInputs, + ScenarioTemplate, + ScenarioSummary, +} from "./types.ts"; +import "./global.css"; +import "./mcp-app.css"; + +interface CallToolResultData { + templates?: ScenarioTemplate[]; + defaultInputs?: ScenarioInputs; +} + +/** Extract templates and defaultInputs from tool result structuredContent */ +function extractResultData(result: CallToolResult): CallToolResultData { + if (!result.structuredContent) return {}; + const { templates, defaultInputs } = + result.structuredContent as CallToolResultData; + return { templates, defaultInputs }; +} + +const APP_INFO = { name: "SaaS Scenario Modeler", version: "1.0.0" }; + +// Local defaults for immediate render (should match server's DEFAULT_INPUTS) +const FALLBACK_INPUTS: ScenarioInputs = { + startingMRR: 50000, + monthlyGrowthRate: 5, + monthlyChurnRate: 3, + grossMargin: 80, + fixedCosts: 30000, +}; + +function ScenarioModeler() { + const [templates, setTemplates] = useState([]); + const [defaultInputs, setDefaultInputs] = + useState(FALLBACK_INPUTS); + + const { app, error } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + onAppCreated: (app) => { + app.ontoolresult = async (result) => { + const { templates, defaultInputs } = extractResultData(result); + if (templates) setTemplates(templates); + if (defaultInputs) setDefaultInputs(defaultInputs); + }; + }, + }); + + if (error) { + return ( +
+
Error: {error.message}
+
+ ); + } + + if (!app) { + return ( +
+
Connecting...
+
+ ); + } + + return ( + + ); +} + +interface ScenarioModelerInnerProps { + templates: ScenarioTemplate[]; + defaultInputs: ScenarioInputs; +} + +function ScenarioModelerInner({ + templates, + defaultInputs, +}: ScenarioModelerInnerProps) { + const [inputs, setInputs] = useState(FALLBACK_INPUTS); + const [selectedTemplateId, setSelectedTemplateId] = useState( + null, + ); + + // Derived state - recalculates when inputs change + const projections = useMemo(() => calculateProjections(inputs), [inputs]); + const summary = useMemo( + () => calculateSummary(projections, inputs), + [projections, inputs], + ); + + // Selected template (if any) + const selectedTemplate = useMemo( + () => templates.find((t) => t.id === selectedTemplateId) ?? null, + [templates, selectedTemplateId], + ); + + // Handlers + const handleInputChange = useCallback( + (key: keyof ScenarioInputs, value: number) => { + setInputs((prev) => ({ ...prev, [key]: value })); + }, + [], + ); + + const handleReset = useCallback(() => { + setInputs(defaultInputs); + setSelectedTemplateId(null); + }, [defaultInputs]); + + const handleTemplateSelect = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setSelectedTemplateId(value || null); + }, + [], + ); + + const handleLoadTemplate = useCallback(() => { + if (selectedTemplate) { + setInputs(selectedTemplate.parameters); + setSelectedTemplateId(null); + } + }, [selectedTemplate]); + + return ( +
+ {/* Header */} +
+

SaaS Scenario Modeler

+
+ + {selectedTemplate && ( + + )} + +
+
+ + {/* Parameters */} +
+

Parameters

+ handleInputChange("startingMRR", v)} + /> + formatPercent(v)} + onChange={(v) => handleInputChange("monthlyGrowthRate", v)} + /> + formatPercent(v)} + onChange={(v) => handleInputChange("monthlyChurnRate", v)} + /> + formatPercent(v)} + onChange={(v) => handleInputChange("grossMargin", v)} + /> + handleInputChange("fixedCosts", v)} + /> +
+ + {/* Chart */} + + + {/* Metrics */} + +
+ ); +} + +interface MetricsSectionProps { + userSummary: ScenarioSummary; + templateSummary: ScenarioSummary | null; + templateName?: string; +} + +function MetricsSection({ + userSummary, + templateSummary, + templateName, +}: MetricsSectionProps) { + const profitVariant = userSummary.totalProfit >= 0 ? "positive" : "negative"; + + // Calculate comparison delta + const profitDelta = templateSummary + ? ((userSummary.totalProfit - templateSummary.totalProfit) / + Math.abs(templateSummary.totalProfit)) * + 100 + : null; + + return ( +
+
+ {/* User's scenario */} +
+

Your Scenario

+
+ + + +
+
+ + {/* Template comparison (only when selected) */} + {templateSummary && templateName && ( +
+

vs. {templateName}

+
+ + = 0 ? "positive" : "negative" + } + /> +
+
+ )} +
+ + {/* Summary row */} +
+ + Break-even:{" "} + + {userSummary.breakEvenMonth + ? `Month ${userSummary.breakEvenMonth}` + : "Not achieved"} + + + + MRR Growth:{" "} + = 0 ? "positive" : "negative"}`} + > + {formatPercent(userSummary.mrrGrowthPct, true)} + + + {profitDelta !== null && ( + + vs. template:{" "} + = 0 ? "positive" : "negative"}`} + > + {formatPercent(profitDelta, true)} + + + )} +
+
+ ); +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/scenario-modeler-server/src/types.ts b/examples/scenario-modeler-server/src/types.ts new file mode 100644 index 00000000..57acb8e1 --- /dev/null +++ b/examples/scenario-modeler-server/src/types.ts @@ -0,0 +1,36 @@ +export interface ScenarioInputs { + startingMRR: number; + monthlyGrowthRate: number; + monthlyChurnRate: number; + grossMargin: number; + fixedCosts: number; +} + +export interface MonthlyProjection { + month: number; // 1-12 + mrr: number; + grossProfit: number; + netProfit: number; + cumulativeRevenue: number; +} + +export interface ScenarioSummary { + endingMRR: number; + arr: number; + totalRevenue: number; + totalProfit: number; + mrrGrowthPct: number; + avgMargin: number; + breakEvenMonth: number | null; +} + +export interface ScenarioTemplate { + id: string; + name: string; + description: string; + icon: string; + parameters: ScenarioInputs; + projections: MonthlyProjection[]; + summary: ScenarioSummary; + keyInsight: string; +} diff --git a/examples/scenario-modeler-server/tsconfig.json b/examples/scenario-modeler-server/tsconfig.json new file mode 100644 index 00000000..fc3c2101 --- /dev/null +++ b/examples/scenario-modeler-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/examples/scenario-modeler-server/vite.config.ts b/examples/scenario-modeler-server/vite.config.ts new file mode 100644 index 00000000..da0af84e --- /dev/null +++ b/examples/scenario-modeler-server/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +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: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/examples/system-monitor-server/README.md b/examples/system-monitor-server/README.md new file mode 100644 index 00000000..3ce91dfc --- /dev/null +++ b/examples/system-monitor-server/README.md @@ -0,0 +1,49 @@ +# Example: System Monitor App + +A demo MCP App that displays real-time OS metrics with a stacked area chart for per-core CPU usage and a bar gauge for memory. + +## Features + +- **Per-Core CPU Monitoring**: Stacked area chart showing individual CPU core utilization over a 1-minute sliding window +- **Memory Usage**: Horizontal bar gauge with color-coded thresholds (green/yellow/red) +- **System Info**: Hostname, platform, and uptime display +- **Auto-Polling**: Automatically starts monitoring on load with 2-second refresh interval +- **Theme Support**: Adapts to light/dark mode preferences + +## 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-system-stats` tool that returns: + +- Raw per-core CPU timing data (idle/total counters) +- Memory usage (used/total/percentage) +- System info (hostname, platform, uptime) + +The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`. + +### App (`src/mcp-app.ts`) + +- Uses Chart.js for the stacked area chart visualization +- Polls the server tool every 2 seconds +- Computes CPU usage percentages client-side from timing deltas +- Maintains a 30-point history (1 minute at 2s intervals) +- Updates all UI elements on each poll diff --git a/examples/system-monitor-server/mcp-app.html b/examples/system-monitor-server/mcp-app.html new file mode 100644 index 00000000..c86a89f4 --- /dev/null +++ b/examples/system-monitor-server/mcp-app.html @@ -0,0 +1,60 @@ + + + + + + System Monitor + + +
+
+

System Monitor

+
+ + + + Ready + +
+
+ +
+

CPU Usage

+
+ +
+
+ +
+

Memory

+
+
+
+
+ --% +
+
-- / --
+
+ +
+

System Info

+
+
+
Hostname
+
--
+
+
+
Platform
+
--
+
+
+
Uptime
+
--
+
+
+
+
+ + + + diff --git a/examples/system-monitor-server/package.json b/examples/system-monitor-server/package.json new file mode 100644 index 00000000..26323078 --- /dev/null +++ b/examples/system-monitor-server/package.json @@ -0,0 +1,31 @@ +{ + "name": "system-monitor-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", + "systeminformation": "^5.27.11", + "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/system-monitor-server/server.ts b/examples/system-monitor-server/server.ts new file mode 100644 index 00000000..bbb2ba36 --- /dev/null +++ b/examples/system-monitor-server/server.ts @@ -0,0 +1,224 @@ +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 os from "node:os"; +import path from "node:path"; +import si from "systeminformation"; +import { z } from "zod"; +import { RESOURCE_URI_META_KEY } from "../../dist/src/app"; + +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001; + +// Schemas - types are derived from these using z.infer +const CpuCoreSchema = z.object({ + idle: z.number(), + total: z.number(), +}); + +const CpuStatsSchema = z.object({ + cores: z.array(CpuCoreSchema), + model: z.string(), + count: z.number(), +}); + +const MemoryStatsSchema = z.object({ + usedBytes: z.number(), + totalBytes: z.number(), + usedPercent: z.number(), + freeBytes: z.number(), + usedFormatted: z.string(), + totalFormatted: z.string(), +}); + +const SystemInfoSchema = z.object({ + hostname: z.string(), + platform: z.string(), + arch: z.string(), + uptime: z.number(), + uptimeFormatted: z.string(), +}); + +const SystemStatsSchema = z.object({ + cpu: CpuStatsSchema, + memory: MemoryStatsSchema, + system: SystemInfoSchema, + timestamp: z.string(), +}); + +// Types derived from schemas +type CpuCore = z.infer; +type MemoryStats = z.infer; +type SystemStats = z.infer; +const DIST_DIR = path.join(import.meta.dirname, "dist"); + +// Returns raw CPU timing data per core (client calculates usage from deltas) +function getCpuSnapshots(): CpuCore[] { + return os.cpus().map((cpu) => { + const times = cpu.times; + const idle = times.idle; + const total = times.user + times.nice + times.sys + times.idle + times.irq; + return { idle, total }; + }); +} + +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + + return parts.length > 0 ? parts.join(" ") : "< 1m"; +} + +function formatBytes(bytes: number): string { + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + + return `${value.toFixed(1)} ${units[unitIndex]}`; +} + +async function getMemoryStats(): Promise { + const mem = await si.mem(); + return { + usedBytes: mem.active, + totalBytes: mem.total, + usedPercent: Math.round((mem.active / mem.total) * 100), + freeBytes: mem.available, + usedFormatted: formatBytes(mem.active), + totalFormatted: formatBytes(mem.total), + }; +} + +const server = new McpServer({ + name: "System Monitor Server", + version: "1.0.0", +}); + +// Register the get-system-stats tool and its associated UI resource +{ + const resourceUri = "ui://system-monitor/mcp-app.html"; + + server.registerTool( + "get-system-stats", + { + title: "Get System Stats", + description: + "Returns current system statistics including per-core CPU usage, memory, and system info.", + inputSchema: {}, + outputSchema: SystemStatsSchema.shape, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async (): Promise => { + const cpuSnapshots = getCpuSnapshots(); + const cpuInfo = os.cpus()[0]; + const memory = await getMemoryStats(); + const uptimeSeconds = os.uptime(); + + const stats: SystemStats = { + cpu: { + cores: cpuSnapshots, + model: cpuInfo?.model ?? "Unknown", + count: os.cpus().length, + }, + memory, + system: { + hostname: os.hostname(), + platform: `${os.platform()} ${os.arch()}`, + arch: os.arch(), + uptime: uptimeSeconds, + uptimeFormatted: formatUptime(uptimeSeconds), + }, + timestamp: new Date().toISOString(), + }; + + return { + content: [{ type: "text", text: JSON.stringify(stats, null, 2) }], + structuredContent: stats, + }; + }, + ); + + server.registerResource( + resourceUri, + resourceUri, + { description: "System Monitor UI" }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + + return { + contents: [ + { + uri: resourceUri, + mimeType: "text/html;profile=mcp-app", + text: html, + }, + ], + }; + }, + ); +} + +const app = express(); +app.use(cors()); +app.use(express.json()); + +app.post("/mcp", async (req: Request, res: Response) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { + transport.close(); + }); + + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } +}); + +const httpServer = app.listen(PORT, () => { + console.log( + `System Monitor 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/system-monitor-server/src/global.css b/examples/system-monitor-server/src/global.css new file mode 100644 index 00000000..97cda440 --- /dev/null +++ b/examples/system-monitor-server/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/system-monitor-server/src/mcp-app.css b/examples/system-monitor-server/src/mcp-app.css new file mode 100644 index 00000000..ac95af44 --- /dev/null +++ b/examples/system-monitor-server/src/mcp-app.css @@ -0,0 +1,233 @@ +:root { + --color-bg: #ffffff; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-success: #10b981; + --color-warning: #f59e0b; + --color-danger: #ef4444; + --color-bar-bg: #e5e7eb; + --color-card-bg: #f9fafb; + --color-border: #e5e7eb; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #111827; + --color-text: #f9fafb; + --color-text-muted: #9ca3af; + --color-primary: #3b82f6; + --color-primary-hover: #60a5fa; + --color-success: #34d399; + --color-warning: #fbbf24; + --color-danger: #f87171; + --color-bar-bg: #374151; + --color-card-bg: #1f2937; + --color-border: #374151; + } +} + +html, body { + margin: 0; + padding: 0; + background: var(--color-bg); + color: var(--color-text); +} + +.main { + width: 600px; + margin: 0 auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Header */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + flex-shrink: 0; +} + +.header-controls { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.btn { + padding: 6px 16px; + min-width: 60px; + font-size: 0.875rem; + font-weight: 500; + border: none; + border-radius: 6px; + background: var(--color-primary); + color: white; + cursor: pointer; + transition: background 0.15s ease; +} + +.btn:hover { + background: var(--color-primary-hover); +} + +.btn.active { + background: var(--color-danger); +} + +.btn.active:hover { + background: #dc2626; +} + +.status { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.status-text { + min-width: 8ch; + font-family: ui-monospace, monospace; + text-align: right; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-text-muted); +} + +.status-indicator.polling { + background: var(--color-success); + animation: pulse 1.5s infinite; +} + +.status-indicator.error { + background: var(--color-danger); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Section titles */ +.section-title { + margin: 0 0 8px 0; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); +} + +/* Chart section */ +.chart-section { + background: var(--color-card-bg); + border-radius: 8px; + padding: 12px; + border: 1px solid var(--color-border); +} + +.chart-container { + position: relative; + height: 200px; +} + +/* Memory section */ +.memory-section { + background: var(--color-card-bg); + border-radius: 8px; + padding: 12px; + border: 1px solid var(--color-border); +} + +.memory-bar-container { + display: flex; + align-items: center; + gap: 12px; +} + +.memory-bar { + flex: 1; + height: 20px; + background: var(--color-bar-bg); + border-radius: 10px; + overflow: hidden; +} + +.memory-bar-fill { + height: 100%; + width: 0%; + background: var(--color-success); + border-radius: 10px; + transition: width 0.3s ease, background 0.3s ease; +} + +.memory-bar-fill.warning { + background: var(--color-warning); +} + +.memory-bar-fill.danger { + background: var(--color-danger); +} + +.memory-percent { + font-size: 1.25rem; + font-weight: 600; + min-width: 50px; + text-align: right; +} + +.memory-detail { + margin-top: 4px; + font-size: 0.875rem; + color: var(--color-text-muted); +} + +/* Info section */ +.info-section { + background: var(--color-card-bg); + border-radius: 8px; + padding: 12px; + border: 1px solid var(--color-border); +} + +.info-list { + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.info-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +.info-item dt { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +.info-item dd { + margin: 0; + font-size: 0.875rem; + font-weight: 500; +} diff --git a/examples/system-monitor-server/src/mcp-app.ts b/examples/system-monitor-server/src/mcp-app.ts new file mode 100644 index 00000000..7c645e03 --- /dev/null +++ b/examples/system-monitor-server/src/mcp-app.ts @@ -0,0 +1,360 @@ +/** + * @file System Monitor App - displays real-time OS metrics with Chart.js + */ +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 for system stats response +interface SystemStats { + cpu: { + cores: Array<{ idle: number; total: number }>; + model: string; + count: number; + }; + memory: { + usedBytes: number; + totalBytes: number; + usedPercent: number; + freeBytes: number; + usedFormatted: string; + totalFormatted: string; + }; + system: { + hostname: string; + platform: string; + arch: string; + uptime: number; + uptimeFormatted: string; + }; + timestamp: string; +} + +// DOM element references +const pollToggleBtn = document.getElementById("poll-toggle-btn")!; +const statusIndicator = document.getElementById("status-indicator")!; +const statusText = document.getElementById("status-text")!; +const cpuChartCanvas = document.getElementById( + "cpu-chart", +) as HTMLCanvasElement; +const memoryBarFill = document.getElementById("memory-bar-fill")!; +const memoryPercent = document.getElementById("memory-percent")!; +const memoryDetail = document.getElementById("memory-detail")!; +const infoHostname = document.getElementById("info-hostname")!; +const infoPlatform = document.getElementById("info-platform")!; +const infoUptime = document.getElementById("info-uptime")!; + +// Polling state +const HISTORY_LENGTH = 30; +const POLL_INTERVAL = 2000; + +interface PollingState { + isPolling: boolean; + intervalId: number | null; + cpuHistory: number[][]; // [timestamp][coreIndex] = usage% + labels: string[]; + coreCount: number; + chart: Chart | null; + previousCpuSnapshots: Array<{ idle: number; total: number }> | null; +} + +const state: PollingState = { + isPolling: false, + intervalId: null, + cpuHistory: [], + labels: [], + coreCount: 0, + chart: null, + previousCpuSnapshots: null, +}; + +// Color palette for CPU cores (distinct colors) +const CORE_COLORS = [ + "rgba(59, 130, 246, 0.7)", // blue + "rgba(16, 185, 129, 0.7)", // green + "rgba(245, 158, 11, 0.7)", // amber + "rgba(239, 68, 68, 0.7)", // red + "rgba(139, 92, 246, 0.7)", // purple + "rgba(236, 72, 153, 0.7)", // pink + "rgba(20, 184, 166, 0.7)", // teal + "rgba(249, 115, 22, 0.7)", // orange + "rgba(34, 197, 94, 0.7)", // emerald + "rgba(168, 85, 247, 0.7)", // violet + "rgba(251, 146, 60, 0.7)", // orange-light + "rgba(74, 222, 128, 0.7)", // green-light + "rgba(96, 165, 250, 0.7)", // blue-light + "rgba(248, 113, 113, 0.7)", // red-light + "rgba(167, 139, 250, 0.7)", // purple-light + "rgba(244, 114, 182, 0.7)", // pink-light +]; + +// Calculate CPU usage percentages from raw timing data +function calculateCpuUsage( + current: Array<{ idle: number; total: number }>, + previous: Array<{ idle: number; total: number }> | null, +): number[] { + if (!previous || previous.length !== current.length) { + return current.map(() => 0); + } + return current.map((cur, i) => { + const prev = previous[i]; + const idleDiff = cur.idle - prev.idle; + const totalDiff = cur.total - prev.total; + if (totalDiff === 0) return 0; + return Math.round((1 - idleDiff / totalDiff) * 100); + }); +} + +function initChart(coreCount: number): Chart { + const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; + const textColor = isDarkMode ? "#9ca3af" : "#6b7280"; + const gridColor = isDarkMode ? "#374151" : "#e5e7eb"; + + const datasets = Array.from({ length: coreCount }, (_, i) => ({ + label: `P${i}`, + data: [] as number[], + fill: true, + backgroundColor: CORE_COLORS[i % CORE_COLORS.length], + borderColor: CORE_COLORS[i % CORE_COLORS.length].replace("0.7", "1"), + borderWidth: 1, + pointRadius: 0, + tension: 0.3, + })); + + return new Chart(cpuChartCanvas, { + type: "line", + data: { + labels: [], + datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 300, + }, + interaction: { + intersect: false, + mode: "index", + }, + plugins: { + legend: { + display: true, + position: "bottom", + labels: { + boxWidth: 12, + padding: 8, + font: { size: 10 }, + color: textColor, + }, + }, + tooltip: { + enabled: true, + callbacks: { + label: (context) => + `${context.dataset.label}: ${context.parsed.y}%`, + }, + }, + }, + scales: { + x: { + display: false, + }, + y: { + stacked: true, + min: 0, + max: coreCount * 100, + ticks: { + callback: (value) => `${value}%`, + color: textColor, + font: { size: 10 }, + }, + grid: { + color: gridColor, + }, + }, + }, + }, + }); +} + +function updateChart(cpuHistory: number[][], labels: string[]): void { + if (!state.chart) return; + + state.chart.data.labels = labels; + + // Transpose: cpuHistory[time][core] -> datasets[core].data[time] + for (let coreIdx = 0; coreIdx < state.coreCount; coreIdx++) { + state.chart.data.datasets[coreIdx].data = cpuHistory.map( + (snapshot) => snapshot[coreIdx] ?? 0, + ); + } + + // Dynamic y-axis scaling + // Calculate max stacked value (sum of all cores at each time point) + const stackedTotals = cpuHistory.map((snapshot) => + snapshot.reduce((sum, val) => sum + val, 0), + ); + const currentMax = Math.max(...stackedTotals, 0); + + // Add 20% headroom, clamp to reasonable bounds + const headroom = 1.2; + const minVisible = state.coreCount * 15; // At least 15% per core visible + const absoluteMax = state.coreCount * 100; + + const dynamicMax = Math.min( + Math.max(currentMax * headroom, minVisible), + absoluteMax, + ); + + state.chart.options.scales!.y!.max = dynamicMax; + + state.chart.update("none"); +} + +function updateMemoryBar(memory: SystemStats["memory"]): void { + const percent = memory.usedPercent; + + memoryBarFill.style.width = `${percent}%`; + memoryBarFill.classList.remove("warning", "danger"); + + if (percent >= 80) { + memoryBarFill.classList.add("danger"); + } else if (percent >= 60) { + memoryBarFill.classList.add("warning"); + } + + memoryPercent.textContent = `${percent}%`; + memoryDetail.textContent = `${memory.usedFormatted} / ${memory.totalFormatted}`; +} + +function updateSystemInfo(system: SystemStats["system"]): void { + infoHostname.textContent = system.hostname; + infoPlatform.textContent = system.platform; + infoUptime.textContent = system.uptimeFormatted; +} + +function updateStatus(text: string, isPolling = false, isError = false): void { + statusText.textContent = text; + statusIndicator.classList.remove("polling", "error"); + + if (isError) { + statusIndicator.classList.add("error"); + } else if (isPolling) { + statusIndicator.classList.add("polling"); + } +} + +// Create app instance +const app = new App({ name: "System Monitor", version: "1.0.0" }); + +async function fetchStats(): Promise { + try { + const result = await app.callServerTool({ + name: "get-system-stats", + arguments: {}, + }); + + const stats = result.structuredContent as unknown as SystemStats; + + // Initialize chart on first data if needed + if (!state.chart && stats.cpu.count > 0) { + state.coreCount = stats.cpu.count; + state.chart = initChart(state.coreCount); + } + + // Calculate CPU usage from raw timing data (client-side) + const coreUsages = calculateCpuUsage( + stats.cpu.cores, + state.previousCpuSnapshots, + ); + state.previousCpuSnapshots = stats.cpu.cores; + state.cpuHistory.push(coreUsages); + state.labels.push(new Date().toLocaleTimeString()); + + // Trim to window size + if (state.cpuHistory.length > HISTORY_LENGTH) { + state.cpuHistory.shift(); + state.labels.shift(); + } + + // Update UI + updateChart(state.cpuHistory, state.labels); + updateMemoryBar(stats.memory); + updateSystemInfo(stats.system); + + const time = new Date().toLocaleTimeString("en-US", { hour12: false }); + updateStatus(time, true); + } catch (error) { + log.error("Failed to fetch stats:", error); + updateStatus("Error", false, true); + } +} + +function startPolling(): void { + if (state.isPolling) return; + + state.isPolling = true; + pollToggleBtn.textContent = "Stop"; + pollToggleBtn.classList.add("active"); + updateStatus("Starting...", true); + + // Immediate first fetch + fetchStats(); + + // Start interval + state.intervalId = window.setInterval(fetchStats, POLL_INTERVAL); +} + +function stopPolling(): void { + if (!state.isPolling) return; + + state.isPolling = false; + if (state.intervalId) { + clearInterval(state.intervalId); + state.intervalId = null; + } + + pollToggleBtn.textContent = "Start"; + pollToggleBtn.classList.remove("active"); + updateStatus("Stopped"); +} + +function togglePolling(): void { + if (state.isPolling) { + stopPolling(); + } else { + startPolling(); + } +} + +// Event listeners +pollToggleBtn.addEventListener("click", togglePolling); + +// Handle theme changes +window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", () => { + if (state.chart) { + state.chart.destroy(); + state.chart = initChart(state.coreCount); + updateChart(state.cpuHistory, state.labels); + } + }); + +// Register handlers and connect +app.onerror = log.error; + +app.connect(new PostMessageTransport(window.parent)); + +// Auto-start polling after a short delay +setTimeout(startPolling, 500); diff --git a/examples/system-monitor-server/tsconfig.json b/examples/system-monitor-server/tsconfig.json new file mode 100644 index 00000000..535267b2 --- /dev/null +++ b/examples/system-monitor-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/system-monitor-server/vite.config.ts b/examples/system-monitor-server/vite.config.ts new file mode 100644 index 00000000..6ff6d997 --- /dev/null +++ b/examples/system-monitor-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 b33cef7c..1ed478ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,6 +109,95 @@ "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": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "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/customer-segmentation-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/scenario-modeler-server": { + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "chart.js": "^4.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "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/simple-host": { "name": "@modelcontextprotocol/ext-apps-host", "version": "1.0.0", @@ -160,6 +249,27 @@ "vite-plugin-singlefile": "^2.3.0" } }, + "examples/system-monitor-server": { + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "chart.js": "^4.4.0", + "systeminformation": "^5.27.11", + "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" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "dev": true, @@ -471,6 +581,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/ext-apps": { "resolved": "", "link": true @@ -724,8 +838,6 @@ }, "node_modules/@types/bun": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.3.tgz", - "integrity": "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==", "dev": true, "license": "MIT", "dependencies": { @@ -751,8 +863,6 @@ }, "node_modules/@types/cors": { "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "dev": true, "license": "MIT", "dependencies": { @@ -1058,8 +1168,6 @@ }, "node_modules/anymatch": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, "license": "ISC", "dependencies": { @@ -1072,8 +1180,6 @@ }, "node_modules/anymatch/node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -1119,8 +1225,6 @@ }, "node_modules/binary-extensions": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "license": "MIT", "engines": { @@ -1203,6 +1307,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": [ @@ -1236,8 +1344,6 @@ }, "node_modules/bun-types": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz", - "integrity": "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1344,6 +1450,16 @@ "node": ">=8" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-error": { "version": "2.1.1", "dev": true, @@ -1354,8 +1470,6 @@ }, "node_modules/chokidar": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -1390,6 +1504,10 @@ "node": ">=12" } }, + "node_modules/cohort-heatmap-server": { + "resolved": "examples/cohort-heatmap-server", + "link": true + }, "node_modules/color-convert": { "version": "2.0.1", "dev": true, @@ -1408,8 +1526,6 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, @@ -1501,6 +1617,10 @@ "dev": true, "license": "MIT" }, + "node_modules/customer-segmentation-server": { + "resolved": "examples/customer-segmentation-server", + "link": true + }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -1823,21 +1943,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -1896,8 +2001,6 @@ }, "node_modules/glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -1993,8 +2096,6 @@ }, "node_modules/ignore-by-default": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true, "license": "ISC" }, @@ -2011,8 +2112,6 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "license": "MIT", "dependencies": { @@ -2024,8 +2123,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -2042,8 +2139,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2272,8 +2367,6 @@ }, "node_modules/nodemon": { "version": "3.1.11", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", - "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2301,8 +2394,6 @@ }, "node_modules/nodemon/node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2312,8 +2403,6 @@ }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", "engines": { @@ -2322,8 +2411,6 @@ }, "node_modules/nodemon/node_modules/minimatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -2335,8 +2422,6 @@ }, "node_modules/nodemon/node_modules/semver": { "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -2348,8 +2433,6 @@ }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", "dependencies": { @@ -2361,8 +2444,6 @@ }, "node_modules/normalize-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", "engines": { @@ -2515,8 +2596,6 @@ }, "node_modules/pstree.remy": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true, "license": "MIT" }, @@ -2588,8 +2667,6 @@ }, "node_modules/readdirp": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", "dependencies": { @@ -2601,8 +2678,6 @@ }, "node_modules/readdirp/node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -2693,6 +2768,10 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/scenario-modeler-server": { + "resolved": "examples/scenario-modeler-server", + "link": true + }, "node_modules/scheduler": { "version": "0.27.0", "license": "MIT" @@ -2841,8 +2920,6 @@ }, "node_modules/simple-update-notifier": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, "license": "MIT", "dependencies": { @@ -2854,8 +2931,6 @@ }, "node_modules/simple-update-notifier/node_modules/semver": { "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -2944,6 +3019,34 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/system-monitor-server": { + "resolved": "examples/system-monitor-server", + "link": true + }, + "node_modules/systeminformation": { + "version": "5.27.11", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, @@ -3013,8 +3116,6 @@ }, "node_modules/touch": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", "dev": true, "license": "ISC", "bin": { @@ -3087,8 +3188,6 @@ }, "node_modules/undefsafe": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index efb6381c..c73a58c1 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "build": "bun build.bun.ts", "build:all": "npm run build && npm run examples:build", "test": "bun test", - "examples:build": "concurrently --kill-others-on-fail 'npm run --workspace=examples/basic-host build' 'npm run --workspace=examples/basic-server-react build' 'npm run --workspace=examples/basic-server-vanillajs build'", + "examples:build": "find examples -maxdepth 1 -mindepth 1 -type d -exec printf '%s\\0' 'npm run --workspace={} build' ';' | xargs -0 concurrently --kill-others-on-fail", "examples:start": "NODE_ENV=development npm run build && concurrently 'npm run examples:start:basic-host' 'npm run examples:start:basic-server-react'", "examples:start:basic-host": "npm run --workspace=examples/basic-host start", "examples:start:basic-server-react": "npm run --workspace=examples/basic-server-react start",