ext-apps/examples/scenario-modeler-server/server.ts
Jerome 65a62a9fae
feat: compile example servers to JavaScript for npm consumption (#259)
* feat: compile example servers to JavaScript for npm consumption

Add TypeScript compilation for server code so the example packages can be
imported and used by consumers without requiring TypeScript compilation.

Changes:
- Add tsconfig.server.json to each example for compiling server code
- Update package.json exports to point to compiled dist/server/server.js
- Add types field pointing to dist/server/server.d.ts
- Update build scripts to include server compilation step
- Fix .ts import extensions to .js in customer-segmentation-server

This enables importing the createServer function from published packages:
  import { createServer } from '@modelcontextprotocol/server-basic-react';

* fix: add type assertion for Nominatim API response

* chore: regenerate schemas

* refactor: split server examples into library and binary entry points

- server.ts: exports createServer() only (library entry point)
- main.ts: entry point with main() that runs the server (binary)
- package.json: adds bin field for npx support, uses inline bun build
- tsconfig.server.json: emitDeclarationOnly, outputs to dist/

This allows:
1. Importing the server as a library: import { createServer } from '@modelcontextprotocol/server-basic-react'
2. Running with npx: npx @modelcontextprotocol/server-basic-react [--stdio]

Added migration script at scripts/migrate-server-examples.ts

* refactor: migrate remaining server examples to library/binary structure

Apply the same refactoring to all 13 remaining server examples:
- scenario-modeler-server, shadertoy-server, map-server
- cohort-heatmap-server, customer-segmentation-server, video-resource-server
- system-monitor-server, budget-allocator-server, sheet-music-server
- threejs-server, integration-server, transcript-server, wiki-explorer-server

Each now has:
- server.ts: exports createServer() (library)
- main.ts: runs the server (binary with shebang)
- bin field in package.json for npx support

* chore: remove unused server-utils.ts from video-resource-server/src

* update

* fix(build): make Windows-compatible by removing chmod and fixing bun externals

- Remove 'chmod +x dist/index.js' which doesn't exist on Windows
- Add '--external ./server.js' to prevent bun from treating the import as a second entry point

* fix(build): use .ts import for bun compatibility

- Change import from './server.js' to './server.ts' in main.ts files
- Remove --external flag since bun should inline everything
- Fixes Windows build where bun couldn't resolve .js to .ts properly

* fix(build): use --external for Windows bun compatibility

Bun on Windows incorrectly treats imported .ts files as entry points.
Use --external './server.js' to work around this, creating a runtime
dependency between index.js and server.js.

* style: fix prettier formatting in pdf-server/main.ts

* fix(build): remove quotes from --external for Windows cmd.exe compatibility

* fix(build): use glob pattern for --external to work on all platforms

The literal path --external ./server.js was not matching the import.
Using glob pattern --external "./server*" correctly externalizes the server module.

* fix(build): use */server.js pattern for --external

The "./server*" pattern was not matching on Windows.
Using "*/server.js" matches any path ending in /server.js which works cross-platform.

* fix(build): use *server.js pattern without slash for Windows compatibility

* update package-lock

* chore: regenerate package-lock.json for cross-platform compatibility

* fix(build): use ./server.js instead of glob pattern for Windows compatibility

* fix(build): use double quotes for banner to fix Windows cmd.exe compatibility

---------

Co-authored-by: Olivier Chafik <ochafik@anthropic.com>
2026-01-14 23:46:17 +00:00

370 lines
10 KiB
TypeScript

import {
RESOURCE_MIME_TYPE,
registerAppResource,
registerAppTool,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type {
CallToolResult,
ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
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
// ============================================================================
/**
* Creates a new MCP server instance with tools and resources registered.
* Each HTTP session needs its own server instance because McpServer only supports one transport.
*/
export function createServer(): McpServer {
const server = new McpServer({
name: "SaaS Scenario Modeler",
version: "1.0.0",
});
// Register tool and resource
{
const resourceUri = "ui://scenario-modeler/mcp-app.html";
registerAppTool(
server,
"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: { ui: { 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,
},
};
},
);
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE, 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: RESOURCE_MIME_TYPE,
text: html,
},
],
};
},
);
}
return server;
}
// ============================================================================
// Server Startup
// ============================================================================