diff --git a/examples/cohort-heatmap-server/README.md b/examples/cohort-heatmap-server/README.md
new file mode 100644
index 0000000..be81c06
--- /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 0000000..2b1a47d
--- /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 0000000..6769b1c
--- /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 0000000..ae65b68
--- /dev/null
+++ b/examples/cohort-heatmap-server/server.ts
@@ -0,0 +1,256 @@
+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+mcp", 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 0000000..b53cf5e
--- /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 0000000..cf5dcca
--- /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 0000000..e27595c
--- /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 (
+
+ );
+}
+
+// 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 0000000..11f02fe
--- /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 0000000..fc3c210
--- /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 0000000..da0af84
--- /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/package-lock.json b/package-lock.json
index a393d34..eb3ea40 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -109,11 +109,29 @@
"vite-plugin-singlefile": "^2.3.0"
}
},
- "examples/budget-allocator-server": {
- "extraneous": true
- },
"examples/cohort-heatmap-server": {
- "extraneous": true
+ "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",
@@ -135,9 +153,6 @@
"vite-plugin-singlefile": "^2.3.0"
}
},
- "examples/scenario-modeler-server": {
- "extraneous": true
- },
"examples/system-monitor-server": {
"version": "1.0.0",
"dependencies": {
@@ -1389,6 +1404,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,