Merge pull request #76 from jonathanhefner/marketing-examples

Add marketing examples
This commit is contained in:
Anton Pidkuiko 2025-12-05 20:55:14 +00:00 • committed by GitHub
commit 45259c584b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 6889 additions and 80 deletions

View file

@ -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 },
],
};
});

View file

@ -106,9 +106,9 @@ async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<s
const content = resource.contents[0];
// Per the MCP App specification, "text/html+mcp" signals this resource is
// indeed for an MCP App UI.
if (content.mimeType !== "text/html+mcp") {
// Per the MCP App specification, "text/html;profile=mcp-app" signals this
// resource is indeed for an MCP App UI.
if (content.mimeType !== "text/html;profile=mcp-app") {
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
}

View file

@ -51,9 +51,9 @@ const server = new McpServer({
return {
contents: [
// Per the MCP App specification, "text/html+mcp" signals to the Host
// that this resource is indeed for an MCP App UI.
{ uri: resourceUri, mimeType: "text/html+mcp", text: html },
// Per the MCP App specification, "text/html;profile=mcp-app" signals
// to the Host that this resource is indeed for an MCP App UI.
{ uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html },
],
};
},

View file

@ -51,9 +51,9 @@ const server = new McpServer({
return {
contents: [
// Per the MCP App specification, "text/html+mcp" signals to the Host
// that this resource is indeed for an MCP App UI.
{ uri: resourceUri, mimeType: "text/html+mcp", text: html },
// Per the MCP App specification, "text/html;profile=mcp-app" signals
// to the Host that this resource is indeed for an MCP App UI.
{ uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html },
],
};
},

View file

@ -0,0 +1,49 @@
# Example: Budget Allocator App
An interactive budget allocation tool demonstrating real-time data visualization with MCP Apps.
## Features
- **Interactive Sliders**: Adjust budget allocation across 5 categories (Marketing, Engineering, Operations, Sales, R&D)
- **Donut Chart**: Real-time visualization of allocation distribution using Chart.js
- **Sparkline Trends**: 24-month historical allocation data per category
- **Percentile Badges**: Compare your allocation vs. industry benchmarks
- **Stage Selector**: Switch between Seed, Series A, Series B, and Growth benchmarks
- **Budget Presets**: Quick selection of $50K, $100K, $250K, or $500K totals
## Running
1. Install dependencies:
```bash
npm install
```
2. Build and start the server:
```bash
npm start
```
The server will listen on `http://localhost:3001/mcp`.
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
## Architecture
### Server (`server.ts`)
Exposes a single `get-budget-data` tool that returns:
- Category definitions with colors and default allocations
- Historical data (~120 data points) - 24 months of allocation history per category
- Industry benchmarks (~60 data points) - Aggregated percentile data by company stage
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
### App (`src/mcp-app.ts`)
- Uses Chart.js for the donut chart visualization
- Renders sparkline trends using inline SVG
- Computes percentile rankings client-side from benchmark data
- Updates all UI elements reactively on slider changes

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Budget Allocator</title>
</head>
<body>
<div class="app-container">
<header class="header">
<h1 class="title">Budget Allocator</h1>
<select id="budget-selector" class="budget-select"></select>
</header>
<section class="chart-section">
<canvas id="budget-chart"></canvas>
</section>
<section class="sliders-section" id="sliders-container"></section>
<footer class="footer">
<div id="status-bar" class="status-bar">
Allocated: $0 / $0
</div>
<div class="comparison-bar">
<span id="comparison-summary">Loading benchmarks...</span>
<label class="stage-label">
Stage:
<select id="stage-selector" class="stage-select"></select>
</label>
</div>
</footer>
</div>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>

View file

@ -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"
}
}

View file

@ -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<typeof BudgetDataResponseSchema>;
type HistoricalMonth = z.infer<typeof HistoricalMonthSchema>;
type StageBenchmark = z.infer<typeof StageBenchmarkSchema>;
// Internal type (not part of API schema - includes trendPerMonth for data generation)
type BudgetCategoryInternal = z.infer<typeof BudgetCategorySchema> & {
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<string, number> = {};
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<string, number> = {};
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<CallToolResult> => {
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<ReadResourceResult> => {
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);

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<string, BenchmarkPercentiles>;
}
interface BudgetConfig {
categories: BudgetCategory[];
presetBudgets: number[];
defaultBudget: number;
currency: string;
currencySymbol: string;
}
interface HistoricalMonth {
month: string;
allocations: Record<string, number>;
}
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<string, number>;
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 = `
<label class="slider-label" style="--category-color: ${category.color}">
<span class="color-dot"></span>
<span class="label-text">${category.name}</span>
</label>
<div class="sparkline-wrapper">
<canvas class="sparkline" width="50" height="28"></canvas>
<span class="sparkline-tooltip">${tooltipText}</span>
</div>
<div class="slider-container">
<input
type="range"
class="slider"
min="0"
max="100"
step="1"
value="${allocation}"
/>
</div>
<span class="slider-value">
<span class="percent">${allocation.toFixed(1)}%</span>
<span class="amount">${formatCurrency(amount)}</span>
</span>
<span class="percentile-badge"></span>
`;
// 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 = `<span class="percentile-icon">${getPercentileIcon(percentile)}</span>${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)}
<span class="status-icon">${statusIcon}</span>
`;
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: <span class="comparison-highlight">${maxDeviationCategory.name}</span>
${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));

View file

@ -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"]
}

View file

@ -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,
},
});

View file

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

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cohort Retention Heatmap</title>
<link rel="stylesheet" href="/src/global.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/mcp-app.tsx"></script>
</body>
</html>

View file

@ -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"
}
}

View file

@ -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<typeof CohortCellSchema>;
type CohortRow = z.infer<typeof CohortRowSchema>;
type CohortData = z.infer<typeof CohortDataSchema>;
// 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<string, RetentionParams> = {
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<ReadResourceResult> => {
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);

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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 <div className={styles.error}>ERROR: {error.message}</div>;
if (!app) return <div className={styles.loading}>Connecting...</div>;
return <CohortHeatmapInner app={app} />;
}
// Inner App with state management
function CohortHeatmapInner({ app }: { app: App }) {
const [data, setData] = useState<CohortData | null>(null);
const [loading, setLoading] = useState(true);
const [selectedMetric, setSelectedMetric] = useState<MetricType>("retention");
const [selectedPeriodType, setSelectedPeriodType] =
useState<PeriodType>("monthly");
const [highlightedCohort, setHighlightedCohort] = useState<number | null>(
null,
);
const [highlightedPeriod, setHighlightedPeriod] = useState<number | null>(
null,
);
const [tooltipData, setTooltipData] = useState<TooltipData | null>(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 (
<main className={styles.container}>
<Header
selectedMetric={selectedMetric}
selectedPeriodType={selectedPeriodType}
onMetricChange={handleMetricChange}
onPeriodTypeChange={handlePeriodTypeChange}
/>
{loading ? (
<div className={styles.loading}>Loading...</div>
) : data ? (
<HeatmapGrid
data={data}
highlightedCohort={highlightedCohort}
highlightedPeriod={highlightedPeriod}
onCellClick={handleCellClick}
onCellHover={setTooltipData}
/>
) : (
<div className={styles.error}>Failed to load data</div>
)}
<Legend />
{tooltipData && <Tooltip {...tooltipData} />}
</main>
);
}
// 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 (
<header className={styles.header}>
<h1 className={styles.title}>Cohort Retention Analysis</h1>
<div className={styles.controls}>
<label className={styles.control}>
<span>Metric:</span>
<select
value={selectedMetric}
onChange={(e) => onMetricChange(e.target.value as MetricType)}
>
<option value="retention">Retention %</option>
<option value="revenue">Revenue Retention</option>
<option value="active">Active Users</option>
</select>
</label>
<label className={styles.control}>
<span>Period:</span>
<select
value={selectedPeriodType}
onChange={(e) => onPeriodTypeChange(e.target.value as PeriodType)}
>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
</select>
</label>
</div>
</header>
);
}
// 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 (
<div className={styles.heatmapWrapper}>
<div className={styles.heatmapGrid} style={gridStyle}>
{/* Header row: empty corner + period labels */}
<div className={styles.headerCorner} />
{data.periods.map((period, i) => (
<div
key={period}
className={`${styles.headerPeriod} ${highlightedPeriod === i ? styles.highlighted : ""}`}
>
{period}
</div>
))}
{/* Data rows */}
{data.cohorts.map((cohort, cohortIndex) => (
<CohortRowComponent
key={cohort.cohortId}
cohort={cohort}
cohortIndex={cohortIndex}
periodCount={data.periods.length}
periodLabels={data.periodLabels}
isHighlighted={highlightedCohort === cohortIndex}
highlightedPeriod={highlightedPeriod}
onCellClick={onCellClick}
onCellHover={onCellHover}
/>
))}
</div>
</div>
);
}
// 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 (
<>
<div
className={`${styles.label} ${isHighlighted ? styles.highlighted : ""}`}
>
<span className={styles.cohortName}>{cohort.cohortLabel}</span>
<span className={styles.cohortSize}>
{formatNumber(cohort.originalUsers)}
</span>
</div>
{Array.from({ length: periodCount }, (_, p) => {
const cellData = cohort.cells.find((c) => c.periodIndex === p);
const isCellHighlighted = isHighlighted || highlightedPeriod === p;
if (!cellData) {
return <div key={p} className={styles.cellEmpty} />;
}
return (
<HeatmapCell
key={p}
cellData={cellData}
cohort={cohort}
periodLabel={periodLabels[p]}
isHighlighted={isCellHighlighted}
onClick={() => 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<HTMLDivElement>) => {
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 (
<div
className={`${styles.cell} ${isHighlighted ? styles.highlighted : ""}`}
style={{ backgroundColor }}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{Math.round(cellData.retention * 100)}
</div>
);
}
// 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 (
<div className={styles.tooltip} style={style}>
<div className={styles.tooltipHeader}>
{cohortLabel} — {periodLabel}
</div>
<div className={styles.tooltipRow}>
<span className={styles.tooltipLabel}>Retention:</span>
<span className={styles.tooltipValue}>
{(retention * 100).toFixed(1)}%
</span>
</div>
<div className={styles.tooltipRow}>
<span className={styles.tooltipLabel}>Users:</span>
<span className={styles.tooltipValue}>
{formatNumber(usersRetained)} / {formatNumber(usersOriginal)}
</span>
</div>
</div>
);
}
// Legend
function Legend() {
return (
<div className={styles.legend}>
<span className={styles.legendItem}>
<span
className={styles.legendColor}
style={{ backgroundColor: getRetentionColor(0.9) }}
/>
80-100%
</span>
<span className={styles.legendItem}>
<span
className={styles.legendColor}
style={{ backgroundColor: getRetentionColor(0.65) }}
/>
50-79%
</span>
<span className={styles.legendItem}>
<span
className={styles.legendColor}
style={{ backgroundColor: getRetentionColor(0.35) }}
/>
20-49%
</span>
<span className={styles.legendItem}>
<span
className={styles.legendColor}
style={{ backgroundColor: getRetentionColor(0.1) }}
/>
0-19%
</span>
</div>
);
}
// Entry point
createRoot(document.getElementById("root")!).render(
<StrictMode>
<CohortHeatmapApp />
</StrictMode>,
);

Some files were not shown because too many files have changed in this diff Show more