Add scenario-modeler-server example

Demo MCP App for SaaS financial planning with:
- Interactive sliders for MRR, growth, churn, margin, and costs
- 12-month projection charts (MRR, Gross Profit, Net Profit)
- 5 pre-built scenario templates (Bootstrapped, VC Rocketship, etc.)
- Template comparison overlay for strategy analysis
- Light/dark theme support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2025-12-04 13:46:38 -06:00
commit 79fa10097b
16 changed files with 1743 additions and 0 deletions

View file

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

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SaaS Scenario Modeler</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/mcp-app.tsx"></script>
</body>
</html>

View file

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

View file

@ -0,0 +1,400 @@
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<typeof ScenarioInputsSchema>;
type MonthlyProjection = z.infer<typeof MonthlyProjectionSchema>;
type ScenarioSummary = z.infer<typeof ScenarioSummarySchema>;
type ScenarioTemplate = z.infer<typeof ScenarioTemplateSchema>;
// ============================================================================
// 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<CallToolResult> => {
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<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [{ uri: resourceUri, mimeType: "text/html+mcp", text: html }],
};
},
);
}
// ============================================================================
// Express Server
// ============================================================================
const app = express();
app.use(cors());
app.use(express.json());
app.post("/mcp", async (req: Request, res: Response) => {
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => {
transport.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
const httpServer = app.listen(PORT, () => {
console.log(
`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);

View file

@ -0,0 +1,18 @@
interface MetricCardProps {
label: string;
value: string;
variant?: "default" | "positive" | "negative";
}
export function MetricCard({
label,
value,
variant = "default",
}: MetricCardProps) {
return (
<div className={`metric-card metric-card--${variant}`}>
<span className="metric-value">{value}</span>
<span className="metric-label">{label}</span>
</div>
);
}

View file

@ -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<HTMLCanvasElement>(null);
const chartRef = useRef<Chart | null>(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 (
<section className="chart-section">
<h2 className="section-title">12-Month Projection</h2>
<div className="chart-container">
<canvas ref={canvasRef} />
</div>
<div className="chart-legend">
<span className="legend-item">
<span style={{ color: "#3b82f6" }}>--</span> MRR
</span>
<span className="legend-item">
<span style={{ color: "#10b981" }}>--</span> Gross Profit
</span>
<span className="legend-item">
<span style={{ color: "#f59e0b" }}>--</span> Net Profit
</span>
{templateProjections && (
<span className="legend-item legend-template">
(dashed = {templateName})
</span>
)}
</div>
</section>
);
}

View file

@ -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 (
<div className="slider-row">
<label className="slider-label">{label}</label>
<input
type="range"
className="slider-input"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<span className="slider-value">{format(value)}</span>
</div>
);
}

View file

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

View file

@ -0,0 +1,25 @@
import { useState, useEffect } from "react";
export type Theme = "light" | "dark";
export function useTheme(): Theme {
const [theme, setTheme] = useState<Theme>(() => {
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;
}

View file

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

View file

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

View file

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

View file

@ -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<ScenarioTemplate[]>([]);
const [defaultInputs, setDefaultInputs] =
useState<ScenarioInputs>(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 (
<main className="main">
<div className="loading">Error: {error.message}</div>
</main>
);
}
if (!app) {
return (
<main className="main">
<div className="loading">Connecting...</div>
</main>
);
}
return (
<ScenarioModelerInner templates={templates} defaultInputs={defaultInputs} />
);
}
interface ScenarioModelerInnerProps {
templates: ScenarioTemplate[];
defaultInputs: ScenarioInputs;
}
function ScenarioModelerInner({
templates,
defaultInputs,
}: ScenarioModelerInnerProps) {
const [inputs, setInputs] = useState<ScenarioInputs>(FALLBACK_INPUTS);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
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<HTMLSelectElement>) => {
const value = e.target.value;
setSelectedTemplateId(value || null);
},
[],
);
const handleLoadTemplate = useCallback(() => {
if (selectedTemplate) {
setInputs(selectedTemplate.parameters);
setSelectedTemplateId(null);
}
}, [selectedTemplate]);
return (
<main className="main">
{/* Header */}
<header className="header">
<h1 className="header-title">SaaS Scenario Modeler</h1>
<div className="header-controls">
<select
className="template-select"
value={selectedTemplateId ?? ""}
onChange={handleTemplateSelect}
>
<option value="">Compare to...</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.icon} {t.name}
</option>
))}
</select>
{selectedTemplate && (
<button className="reset-button" onClick={handleLoadTemplate}>
Load
</button>
)}
<button className="reset-button" onClick={handleReset}>
Reset
</button>
</div>
</header>
{/* Parameters */}
<section className="parameters-section">
<h2 className="section-title">Parameters</h2>
<SliderRow
label="Starting MRR"
value={inputs.startingMRR}
min={10000}
max={500000}
step={5000}
format={formatCurrencySlider}
onChange={(v) => handleInputChange("startingMRR", v)}
/>
<SliderRow
label="Growth Rate"
value={inputs.monthlyGrowthRate}
min={0}
max={20}
step={0.5}
format={(v) => formatPercent(v)}
onChange={(v) => handleInputChange("monthlyGrowthRate", v)}
/>
<SliderRow
label="Churn Rate"
value={inputs.monthlyChurnRate}
min={0}
max={15}
step={0.5}
format={(v) => formatPercent(v)}
onChange={(v) => handleInputChange("monthlyChurnRate", v)}
/>
<SliderRow
label="Gross Margin"
value={inputs.grossMargin}
min={50}
max={95}
step={5}
format={(v) => formatPercent(v)}
onChange={(v) => handleInputChange("grossMargin", v)}
/>
<SliderRow
label="Fixed Costs"
value={inputs.fixedCosts}
min={5000}
max={200000}
step={5000}
format={formatCurrencySlider}
onChange={(v) => handleInputChange("fixedCosts", v)}
/>
</section>
{/* Chart */}
<ProjectionChart
userProjections={projections}
templateProjections={selectedTemplate?.projections ?? null}
templateName={selectedTemplate?.name}
/>
{/* Metrics */}
<MetricsSection
userSummary={summary}
templateSummary={selectedTemplate?.summary ?? null}
templateName={selectedTemplate?.name}
/>
</main>
);
}
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 (
<section className="metrics-section">
<div className="metrics-comparison">
{/* User's scenario */}
<div className="metrics-column">
<h3>Your Scenario</h3>
<div className="metrics-grid">
<MetricCard
label="End MRR"
value={formatCurrency(userSummary.endingMRR)}
/>
<MetricCard
label="Total Revenue"
value={formatCurrency(userSummary.totalRevenue)}
/>
<MetricCard
label="Total Profit"
value={formatCurrency(userSummary.totalProfit)}
variant={profitVariant}
/>
</div>
</div>
{/* Template comparison (only when selected) */}
{templateSummary && templateName && (
<div className="metrics-column template">
<h3>vs. {templateName}</h3>
<div className="metrics-grid">
<MetricCard
label="End MRR"
value={formatCurrency(templateSummary.endingMRR)}
/>
<MetricCard
label="Total Profit"
value={formatCurrency(templateSummary.totalProfit)}
variant={
templateSummary.totalProfit >= 0 ? "positive" : "negative"
}
/>
</div>
</div>
)}
</div>
{/* Summary row */}
<div className="metrics-summary">
<span className="summary-item">
Break-even:{" "}
<span className="summary-value">
{userSummary.breakEvenMonth
? `Month ${userSummary.breakEvenMonth}`
: "Not achieved"}
</span>
</span>
<span className="summary-item">
MRR Growth:{" "}
<span
className={`summary-value ${userSummary.mrrGrowthPct >= 0 ? "positive" : "negative"}`}
>
{formatPercent(userSummary.mrrGrowthPct, true)}
</span>
</span>
{profitDelta !== null && (
<span className="summary-item">
vs. template:{" "}
<span
className={`summary-value ${profitDelta >= 0 ? "positive" : "negative"}`}
>
{formatPercent(profitDelta, true)}
</span>
</span>
)}
</div>
</section>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ScenarioModeler />
</StrictMode>,
);

View file

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

View file

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

View file

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