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:
parent
f81d17f385
commit
79fa10097b
17 changed files with 1743 additions and 0 deletions
50
examples/scenario-modeler-server/README.md
Normal file
50
examples/scenario-modeler-server/README.md
Normal 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
|
||||||
12
examples/scenario-modeler-server/mcp-app.html
Normal file
12
examples/scenario-modeler-server/mcp-app.html
Normal 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>
|
||||||
35
examples/scenario-modeler-server/package.json
Normal file
35
examples/scenario-modeler-server/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
400
examples/scenario-modeler-server/server.ts
Normal file
400
examples/scenario-modeler-server/server.ts
Normal 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);
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
examples/scenario-modeler-server/src/global.css
Normal file
14
examples/scenario-modeler-server/src/global.css
Normal 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;
|
||||||
|
}
|
||||||
25
examples/scenario-modeler-server/src/hooks/useTheme.ts
Normal file
25
examples/scenario-modeler-server/src/hooks/useTheme.ts
Normal 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;
|
||||||
|
}
|
||||||
72
examples/scenario-modeler-server/src/lib/calculations.ts
Normal file
72
examples/scenario-modeler-server/src/lib/calculations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
44
examples/scenario-modeler-server/src/lib/formatters.ts
Normal file
44
examples/scenario-modeler-server/src/lib/formatters.ts
Normal 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}`;
|
||||||
|
}
|
||||||
335
examples/scenario-modeler-server/src/mcp-app.css
Normal file
335
examples/scenario-modeler-server/src/mcp-app.css
Normal 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;
|
||||||
|
}
|
||||||
336
examples/scenario-modeler-server/src/mcp-app.tsx
Normal file
336
examples/scenario-modeler-server/src/mcp-app.tsx
Normal 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>,
|
||||||
|
);
|
||||||
36
examples/scenario-modeler-server/src/types.ts
Normal file
36
examples/scenario-modeler-server/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
20
examples/scenario-modeler-server/tsconfig.json
Normal file
20
examples/scenario-modeler-server/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
25
examples/scenario-modeler-server/vite.config.ts
Normal file
25
examples/scenario-modeler-server/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
80
package-lock.json
generated
80
package-lock.json
generated
|
|
@ -173,6 +173,82 @@
|
||||||
"vite-plugin-singlefile": "^2.3.0"
|
"vite-plugin-singlefile": "^2.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"examples/scenario-modeler-server": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/ext-apps": "../..",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vite-plugin-singlefile": "^2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples/simple-host": {
|
||||||
|
"name": "@modelcontextprotocol/ext-apps-host",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"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": {
|
"examples/system-monitor-server": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2692,6 +2768,10 @@
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/scenario-modeler-server": {
|
||||||
|
"resolved": "examples/scenario-modeler-server",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue