diff --git a/examples/scenario-modeler-server/README.md b/examples/scenario-modeler-server/README.md new file mode 100644 index 0000000..4625169 --- /dev/null +++ b/examples/scenario-modeler-server/README.md @@ -0,0 +1,50 @@ +# Example: SaaS Scenario Modeler + +A React-based demo MCP App that lets users adjust SaaS business parameters and see real-time 12-month projections of revenue, costs, and profitability with comparison against pre-built scenario templates. + +## Features + +- **Interactive Parameters**: 5 sliders for Starting MRR, Growth Rate, Churn Rate, Gross Margin, and Fixed Costs +- **12-Month Projections**: Line chart showing MRR, Gross Profit, and Net Profit over time +- **Scenario Templates**: 5 pre-built business strategies (Bootstrapped, VC Rocketship, Cash Cow, Turnaround, Efficient Growth) +- **Template Comparison**: Overlay dashed lines to compare your scenario against any template +- **Summary Metrics**: Key metrics including Ending MRR, Total Revenue, Total Profit, and break-even month +- **Theme Support**: Adapts to light/dark mode preferences + +## Running + +1. Install dependencies: + + ```bash + npm install + ``` + +2. Build and start the server: + + ```bash + npm start + ``` + + The server will listen on `http://localhost:3001/mcp`. + +3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host. + +## Architecture + +### Server (`server.ts`) + +Exposes a single `get-scenario-data` tool that returns: + +- 5 pre-built scenario templates with parameters, projections, and summaries +- Default input values for the sliders +- Optionally computes custom projections when `customInputs` are provided + +The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`. + +### App (`src/`) + +- Built with React for reactive slider updates and derived state management +- Uses Chart.js for the line chart visualization +- All projection calculations run client-side for instant slider feedback +- Components: `SliderRow`, `MetricCard`, `ProjectionChart` +- Template comparison shown as dashed overlay lines on the chart diff --git a/examples/scenario-modeler-server/mcp-app.html b/examples/scenario-modeler-server/mcp-app.html new file mode 100644 index 0000000..60de710 --- /dev/null +++ b/examples/scenario-modeler-server/mcp-app.html @@ -0,0 +1,12 @@ + + + + + + SaaS Scenario Modeler + + +
+ + + diff --git a/examples/scenario-modeler-server/package.json b/examples/scenario-modeler-server/package.json new file mode 100644 index 0000000..256626c --- /dev/null +++ b/examples/scenario-modeler-server/package.json @@ -0,0 +1,35 @@ +{ + "name": "scenario-modeler-server", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "INPUT=mcp-app.html vite build", + "watch": "INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "NODE_ENV=development npm run build && npm run serve", + "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "chart.js": "^4.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/scenario-modeler-server/server.ts b/examples/scenario-modeler-server/server.ts new file mode 100644 index 0000000..a832252 --- /dev/null +++ b/examples/scenario-modeler-server/server.ts @@ -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; +type MonthlyProjection = z.infer; +type ScenarioSummary = z.infer; +type ScenarioTemplate = z.infer; + +// ============================================================================ +// Calculations +// ============================================================================ + +function calculateProjections(inputs: ScenarioInputs): MonthlyProjection[] { + const { + startingMRR, + monthlyGrowthRate, + monthlyChurnRate, + grossMargin, + fixedCosts, + } = inputs; + + const netGrowthRate = (monthlyGrowthRate - monthlyChurnRate) / 100; + const projections: MonthlyProjection[] = []; + let cumulativeRevenue = 0; + + for (let month = 1; month <= 12; month++) { + const mrr = startingMRR * Math.pow(1 + netGrowthRate, month); + const grossProfit = mrr * (grossMargin / 100); + const netProfit = grossProfit - fixedCosts; + cumulativeRevenue += mrr; + + projections.push({ + month, + mrr, + grossProfit, + netProfit, + cumulativeRevenue, + }); + } + + return projections; +} + +function calculateSummary( + projections: MonthlyProjection[], + inputs: ScenarioInputs, +): ScenarioSummary { + const endingMRR = projections[11].mrr; + const arr = endingMRR * 12; + const totalRevenue = projections.reduce((sum, p) => sum + p.mrr, 0); + const totalProfit = projections.reduce((sum, p) => sum + p.netProfit, 0); + const mrrGrowthPct = + ((endingMRR - inputs.startingMRR) / inputs.startingMRR) * 100; + const avgMargin = (totalProfit / totalRevenue) * 100; + + const breakEvenProjection = projections.find((p) => p.netProfit >= 0); + const breakEvenMonth = breakEvenProjection?.month ?? null; + + return { + endingMRR, + arr, + totalRevenue, + totalProfit, + mrrGrowthPct, + avgMargin, + breakEvenMonth, + }; +} + +function calculateScenario(inputs: ScenarioInputs) { + const projections = calculateProjections(inputs); + const summary = calculateSummary(projections, inputs); + return { projections, summary }; +} + +function buildTemplate( + id: string, + name: string, + description: string, + icon: string, + parameters: ScenarioInputs, + keyInsight: string, +): ScenarioTemplate { + const { projections, summary } = calculateScenario(parameters); + return { + id, + name, + description, + icon, + parameters, + projections, + summary, + keyInsight, + }; +} + +// ============================================================================ +// Pre-defined Templates +// ============================================================================ + +const SCENARIO_TEMPLATES: ScenarioTemplate[] = [ + buildTemplate( + "bootstrapped", + "Bootstrapped Growth", + "Low burn, steady growth, path to profitability", + "🌱", + { + startingMRR: 30000, + monthlyGrowthRate: 4, + monthlyChurnRate: 2, + grossMargin: 85, + fixedCosts: 20000, + }, + "Profitable by month 1, but slower scale", + ), + buildTemplate( + "vc-rocketship", + "VC Rocketship", + "High burn, explosive growth, raise more later", + "🚀", + { + startingMRR: 100000, + monthlyGrowthRate: 15, + monthlyChurnRate: 5, + grossMargin: 70, + fixedCosts: 150000, + }, + "Loses money early but ends at 3x MRR", + ), + buildTemplate( + "cash-cow", + "Cash Cow", + "Mature product, high margin, stable revenue", + "🐄", + { + startingMRR: 80000, + monthlyGrowthRate: 2, + monthlyChurnRate: 1, + grossMargin: 90, + fixedCosts: 40000, + }, + "Consistent profitability, low risk", + ), + buildTemplate( + "turnaround", + "Turnaround", + "Fighting churn, rebuilding product-market fit", + "🔄", + { + startingMRR: 60000, + monthlyGrowthRate: 6, + monthlyChurnRate: 8, + grossMargin: 75, + fixedCosts: 50000, + }, + "Negative net growth requires urgent action", + ), + buildTemplate( + "efficient-growth", + "Efficient Growth", + "Balanced approach with sustainable economics", + "⚖️", + { + startingMRR: 50000, + monthlyGrowthRate: 8, + monthlyChurnRate: 3, + grossMargin: 80, + fixedCosts: 35000, + }, + "Good growth with path to profitability", + ), +]; + +const DEFAULT_INPUTS: ScenarioInputs = { + startingMRR: 50000, + monthlyGrowthRate: 5, + monthlyChurnRate: 3, + grossMargin: 80, + fixedCosts: 30000, +}; + +// ============================================================================ +// Formatters for text output +// ============================================================================ + +function formatCurrency(value: number): string { + const absValue = Math.abs(value); + const sign = value < 0 ? "-" : ""; + if (absValue >= 1_000_000) { + return `${sign}$${(absValue / 1_000_000).toFixed(2)}M`; + } + if (absValue >= 1_000) { + return `${sign}$${(absValue / 1_000).toFixed(1)}K`; + } + return `${sign}$${Math.round(absValue)}`; +} + +function formatScenarioSummary( + summary: ScenarioSummary, + label: string, +): string { + return [ + `${label}:`, + ` Ending MRR: ${formatCurrency(summary.endingMRR)}`, + ` ARR: ${formatCurrency(summary.arr)}`, + ` Total Revenue: ${formatCurrency(summary.totalRevenue)}`, + ` Total Profit: ${formatCurrency(summary.totalProfit)}`, + ` MRR Growth: ${summary.mrrGrowthPct.toFixed(1)}%`, + ` Break-even: ${summary.breakEvenMonth ? `Month ${summary.breakEvenMonth}` : "Not achieved"}`, + ].join("\n"); +} + +// ============================================================================ +// MCP Server +// ============================================================================ + +const server = new McpServer({ + name: "SaaS Scenario Modeler", + version: "1.0.0", +}); + +// Register tool and resource +{ + const resourceUri = "ui://scenario-modeler/mcp-app.html"; + + server.registerTool( + "get-scenario-data", + { + title: "Get Scenario Data", + description: + "Returns SaaS scenario templates and optionally computes custom projections for given inputs", + inputSchema: GetScenarioDataInputSchema.shape, + outputSchema: GetScenarioDataOutputSchema.shape, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async (args: { + customInputs?: ScenarioInputs; + }): Promise => { + const customScenario = args.customInputs + ? calculateScenario(args.customInputs) + : undefined; + + const text = [ + "SaaS Scenario Modeler", + "=".repeat(40), + "", + "Available Templates:", + ...SCENARIO_TEMPLATES.map( + (t) => ` ${t.icon} ${t.name}: ${t.description}`, + ), + "", + customScenario + ? formatScenarioSummary(customScenario.summary, "Custom Scenario") + : "Use customInputs parameter to compute projections for a specific scenario.", + ].join("\n"); + + return { + content: [{ type: "text", text }], + structuredContent: { + templates: SCENARIO_TEMPLATES, + defaultInputs: DEFAULT_INPUTS, + customProjections: customScenario?.projections, + customSummary: customScenario?.summary, + }, + }; + }, + ); + + server.registerResource( + resourceUri, + resourceUri, + { description: "SaaS Scenario Modeler UI" }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [{ uri: resourceUri, mimeType: "text/html+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); diff --git a/examples/scenario-modeler-server/src/components/MetricCard.tsx b/examples/scenario-modeler-server/src/components/MetricCard.tsx new file mode 100644 index 0000000..8ab2b07 --- /dev/null +++ b/examples/scenario-modeler-server/src/components/MetricCard.tsx @@ -0,0 +1,18 @@ +interface MetricCardProps { + label: string; + value: string; + variant?: "default" | "positive" | "negative"; +} + +export function MetricCard({ + label, + value, + variant = "default", +}: MetricCardProps) { + return ( +
+ {value} + {label} +
+ ); +} diff --git a/examples/scenario-modeler-server/src/components/ProjectionChart.tsx b/examples/scenario-modeler-server/src/components/ProjectionChart.tsx new file mode 100644 index 0000000..1a10d2d --- /dev/null +++ b/examples/scenario-modeler-server/src/components/ProjectionChart.tsx @@ -0,0 +1,206 @@ +import { useRef, useEffect } from "react"; +import { Chart, registerables } from "chart.js"; +import { useTheme } from "../hooks/useTheme.ts"; +import type { MonthlyProjection } from "../types.ts"; + +Chart.register(...registerables); + +interface ProjectionChartProps { + userProjections: MonthlyProjection[]; + templateProjections: MonthlyProjection[] | null; + templateName?: string; +} + +export function ProjectionChart({ + userProjections, + templateProjections, + templateName, +}: ProjectionChartProps) { + const canvasRef = useRef(null); + const chartRef = useRef(null); + const theme = useTheme(); + + // Create chart on mount, rebuild on theme change + useEffect(() => { + if (!canvasRef.current) return; + + const textColor = theme === "dark" ? "#9ca3af" : "#6b7280"; + const gridColor = theme === "dark" ? "#374151" : "#e5e7eb"; + + chartRef.current = new Chart(canvasRef.current, { + type: "line", + data: { + labels: Array.from({ length: 12 }, (_, i) => `M${i + 1}`), + datasets: [ + // User scenario (solid lines) + { + label: "MRR", + borderColor: "#3b82f6", + backgroundColor: "rgba(59, 130, 246, 0.1)", + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "Gross Profit", + borderColor: "#10b981", + backgroundColor: "rgba(16, 185, 129, 0.1)", + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + }, + { + label: "Net Profit", + borderColor: "#f59e0b", + backgroundColor: "rgba(245, 158, 11, 0.1)", + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + }, + // Template comparison (dashed lines) + { + label: "Template MRR", + borderColor: "#3b82f6", + borderDash: [5, 5], + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 1.5, + hidden: true, + }, + { + label: "Template Gross", + borderColor: "#10b981", + borderDash: [5, 5], + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 1.5, + hidden: true, + }, + { + label: "Template Net", + borderColor: "#f59e0b", + borderDash: [5, 5], + data: [], + fill: false, + tension: 0.3, + pointRadius: 0, + borderWidth: 1.5, + hidden: true, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: "index", + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: theme === "dark" ? "#1f2937" : "#ffffff", + titleColor: theme === "dark" ? "#f9fafb" : "#111827", + bodyColor: theme === "dark" ? "#9ca3af" : "#6b7280", + borderColor: gridColor, + borderWidth: 1, + callbacks: { + label: (context) => { + const value = context.parsed.y; + if (value === null) return ""; + const formatted = + Math.abs(value) >= 1000 + ? `$${(value / 1000).toFixed(1)}K` + : `$${value.toFixed(0)}`; + return `${context.dataset.label}: ${formatted}`; + }, + }, + }, + }, + scales: { + y: { + grid: { color: gridColor }, + ticks: { + color: textColor, + callback: (value) => { + const num = Number(value); + if (Math.abs(num) >= 1000) { + return `$${(num / 1000).toFixed(0)}K`; + } + return `$${num}`; + }, + }, + }, + x: { + grid: { display: false }, + ticks: { color: textColor }, + }, + }, + }, + }); + + return () => chartRef.current?.destroy(); + }, [theme]); + + // Update data when projections change + useEffect(() => { + if (!chartRef.current) return; + + const chart = chartRef.current; + chart.data.datasets[0].data = userProjections.map((p) => p.mrr); + chart.data.datasets[1].data = userProjections.map((p) => p.grossProfit); + chart.data.datasets[2].data = userProjections.map((p) => p.netProfit); + + if (templateProjections) { + chart.data.datasets[3].data = templateProjections.map((p) => p.mrr); + chart.data.datasets[4].data = templateProjections.map( + (p) => p.grossProfit, + ); + chart.data.datasets[5].data = templateProjections.map((p) => p.netProfit); + chart.data.datasets[3].hidden = false; + chart.data.datasets[4].hidden = false; + chart.data.datasets[5].hidden = false; + } else { + chart.data.datasets[3].hidden = true; + chart.data.datasets[4].hidden = true; + chart.data.datasets[5].hidden = true; + } + + chart.update("none"); // Skip animation for smoother slider updates + }, [userProjections, templateProjections]); + + return ( +
+

12-Month Projection

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

SaaS Scenario Modeler

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

Parameters

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

Your Scenario

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

vs. {templateName}

+
+ + = 0 ? "positive" : "negative" + } + /> +
+
+ )} +
+ + {/* Summary row */} +
+ + Break-even:{" "} + + {userSummary.breakEvenMonth + ? `Month ${userSummary.breakEvenMonth}` + : "Not achieved"} + + + + MRR Growth:{" "} + = 0 ? "positive" : "negative"}`} + > + {formatPercent(userSummary.mrrGrowthPct, true)} + + + {profitDelta !== null && ( + + vs. template:{" "} + = 0 ? "positive" : "negative"}`} + > + {formatPercent(profitDelta, true)} + + + )} +
+
+ ); +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/scenario-modeler-server/src/types.ts b/examples/scenario-modeler-server/src/types.ts new file mode 100644 index 0000000..57acb8e --- /dev/null +++ b/examples/scenario-modeler-server/src/types.ts @@ -0,0 +1,36 @@ +export interface ScenarioInputs { + startingMRR: number; + monthlyGrowthRate: number; + monthlyChurnRate: number; + grossMargin: number; + fixedCosts: number; +} + +export interface MonthlyProjection { + month: number; // 1-12 + mrr: number; + grossProfit: number; + netProfit: number; + cumulativeRevenue: number; +} + +export interface ScenarioSummary { + endingMRR: number; + arr: number; + totalRevenue: number; + totalProfit: number; + mrrGrowthPct: number; + avgMargin: number; + breakEvenMonth: number | null; +} + +export interface ScenarioTemplate { + id: string; + name: string; + description: string; + icon: string; + parameters: ScenarioInputs; + projections: MonthlyProjection[]; + summary: ScenarioSummary; + keyInsight: string; +} diff --git a/examples/scenario-modeler-server/tsconfig.json b/examples/scenario-modeler-server/tsconfig.json new file mode 100644 index 0000000..fc3c210 --- /dev/null +++ b/examples/scenario-modeler-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/examples/scenario-modeler-server/vite.config.ts b/examples/scenario-modeler-server/vite.config.ts new file mode 100644 index 0000000..da0af84 --- /dev/null +++ b/examples/scenario-modeler-server/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/package-lock.json b/package-lock.json index 0529bb7..1ed478c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -173,6 +173,82 @@ "vite-plugin-singlefile": "^2.3.0" } }, + "examples/scenario-modeler-server": { + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "chart.js": "^4.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, + "examples/simple-host": { + "name": "@modelcontextprotocol/ext-apps-host", + "version": "1.0.0", + "extraneous": true, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@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", + "bun": "^1.3.2", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "express": "^5.1.0", + "prettier": "^3.6.2", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0", + "vitest": "^3.2.4" + } + }, + "examples/simple-server": { + "version": "1.0.0", + "extraneous": true, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@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.7.2", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, "examples/system-monitor-server": { "version": "1.0.0", "dependencies": { @@ -2692,6 +2768,10 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/scenario-modeler-server": { + "resolved": "examples/scenario-modeler-server", + "link": true + }, "node_modules/scheduler": { "version": "0.27.0", "license": "MIT"