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
1 changed files with 1743 additions and 0 deletions
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>,
|
||||
);
|
||||
Loading…
Reference in a new issue