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 (
+
+ {label}
+ 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 */}
+
+
+ {/* 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"