* 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>
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
/**
|
|
* @file Customer Segmentation Explorer - interactive scatter/bubble visualization
|
|
*/
|
|
import {
|
|
App,
|
|
applyHostStyleVariables,
|
|
applyHostFonts,
|
|
applyDocumentTheme,
|
|
type McpUiHostContext,
|
|
} from "@modelcontextprotocol/ext-apps";
|
|
import { Chart, registerables } from "chart.js";
|
|
import "./global.css";
|
|
import "./mcp-app.css";
|
|
import type { Customer, SegmentSummary, MetricName } from "./types.js";
|
|
import { SEGMENT_COLORS, METRIC_LABELS } from "./types.js";
|
|
|
|
// Register Chart.js components
|
|
Chart.register(...registerables);
|
|
|
|
const log = {
|
|
info: console.log.bind(console, "[APP]"),
|
|
error: console.error.bind(console, "[APP]"),
|
|
};
|
|
|
|
// DOM element references
|
|
const mainEl = document.querySelector(".main") as HTMLElement;
|
|
const xAxisSelect = document.getElementById("x-axis") as HTMLSelectElement;
|
|
const yAxisSelect = document.getElementById("y-axis") as HTMLSelectElement;
|
|
const sizeMetricSelect = document.getElementById(
|
|
"size-metric",
|
|
) as HTMLSelectElement;
|
|
const chartCanvas = document.getElementById(
|
|
"scatter-chart",
|
|
) as HTMLCanvasElement;
|
|
const legendContainer = document.getElementById("legend")!;
|
|
const detailPanel = document.getElementById("detail-panel")!;
|
|
|
|
// App state
|
|
interface AppState {
|
|
customers: Customer[];
|
|
segments: SegmentSummary[];
|
|
chart: Chart | null;
|
|
xAxis: MetricName;
|
|
yAxis: MetricName;
|
|
sizeMetric: string;
|
|
hiddenSegments: Set<string>;
|
|
selectedCustomer: Customer | null;
|
|
}
|
|
|
|
const state: AppState = {
|
|
customers: [],
|
|
segments: [],
|
|
chart: null,
|
|
xAxis: "annualRevenue",
|
|
yAxis: "engagementScore",
|
|
sizeMetric: "off",
|
|
hiddenSegments: new Set(),
|
|
selectedCustomer: null,
|
|
};
|
|
|
|
// Format numbers for display
|
|
function formatValue(value: number, metric: MetricName): string {
|
|
switch (metric) {
|
|
case "annualRevenue":
|
|
if (value >= 1_000_000) {
|
|
return `$${(value / 1_000_000).toFixed(1)}M`;
|
|
}
|
|
return `$${(value / 1_000).toFixed(0)}K`;
|
|
case "employeeCount":
|
|
return value.toLocaleString();
|
|
case "accountAge":
|
|
return `${value}mo`;
|
|
case "engagementScore":
|
|
return `${value}`;
|
|
case "supportTickets":
|
|
return `${value}`;
|
|
case "nps":
|
|
return value >= 0 ? `+${value}` : `${value}`;
|
|
default:
|
|
return `${value}`;
|
|
}
|
|
}
|
|
|
|
// Get min/max for a metric across all customers
|
|
function getMetricRange(
|
|
customers: Customer[],
|
|
metric: MetricName,
|
|
): { min: number; max: number } {
|
|
const values = customers.map((c) => c[metric] as number);
|
|
return {
|
|
min: Math.min(...values),
|
|
max: Math.max(...values),
|
|
};
|
|
}
|
|
|
|
// Normalize value to bubble radius (6-30px range)
|
|
function normalizeToRadius(value: number, min: number, max: number): number {
|
|
if (max === min) return 12;
|
|
const normalized = (value - min) / (max - min);
|
|
return 6 + normalized * 24;
|
|
}
|
|
|
|
// Get filtered customers based on current state
|
|
function getFilteredCustomers(): Customer[] {
|
|
return state.customers.filter((c) => !state.hiddenSegments.has(c.segment));
|
|
}
|
|
|
|
// Build chart datasets from customers
|
|
function buildDatasets(): Chart["data"]["datasets"] {
|
|
const customers = getFilteredCustomers();
|
|
const segments = [...new Set(customers.map((c) => c.segment))];
|
|
|
|
// Calculate size range if size metric is enabled
|
|
let sizeRange: { min: number; max: number } | null = null;
|
|
if (state.sizeMetric !== "off") {
|
|
sizeRange = getMetricRange(state.customers, state.sizeMetric as MetricName);
|
|
}
|
|
|
|
return segments.map((segment) => {
|
|
const segmentCustomers = customers.filter((c) => c.segment === segment);
|
|
const color = SEGMENT_COLORS[segment] || "#888888";
|
|
|
|
return {
|
|
label: segment,
|
|
data: segmentCustomers.map((c) => ({
|
|
x: c[state.xAxis] as number,
|
|
y: c[state.yAxis] as number,
|
|
r:
|
|
sizeRange && state.sizeMetric !== "off"
|
|
? normalizeToRadius(
|
|
c[state.sizeMetric as MetricName] as number,
|
|
sizeRange.min,
|
|
sizeRange.max,
|
|
)
|
|
: 8,
|
|
customer: c,
|
|
})),
|
|
backgroundColor: color + "aa",
|
|
borderColor: color,
|
|
borderWidth: 1,
|
|
hoverBackgroundColor: color,
|
|
hoverBorderColor: "#ffffff",
|
|
hoverBorderWidth: 2,
|
|
};
|
|
});
|
|
}
|
|
|
|
// Hidden element for resolving CSS color values (reused to avoid DOM thrashing)
|
|
let colorResolver: HTMLDivElement | null = null;
|
|
|
|
// Resolve a CSS color value (handles light-dark() function)
|
|
function resolveColor(cssValue: string, fallback: string): string {
|
|
if (!cssValue) return fallback;
|
|
// If it's a simple color value, return it directly
|
|
if (!cssValue.includes("light-dark(")) return cssValue;
|
|
// Create resolver element once and keep it hidden
|
|
if (!colorResolver) {
|
|
colorResolver = document.createElement("div");
|
|
colorResolver.style.position = "absolute";
|
|
colorResolver.style.visibility = "hidden";
|
|
colorResolver.style.pointerEvents = "none";
|
|
document.body.appendChild(colorResolver);
|
|
}
|
|
colorResolver.style.color = cssValue;
|
|
return getComputedStyle(colorResolver).color || fallback;
|
|
}
|
|
|
|
// Get colors from CSS variables
|
|
function getChartColors(): { textColor: string; gridColor: string } {
|
|
const style = getComputedStyle(document.documentElement);
|
|
const rawTextColor = style.getPropertyValue("--color-text-secondary").trim();
|
|
const rawGridColor = style.getPropertyValue("--color-border-primary").trim();
|
|
return {
|
|
textColor: resolveColor(rawTextColor, "#6b7280"),
|
|
gridColor: resolveColor(rawGridColor, "#e5e7eb"),
|
|
};
|
|
}
|
|
|
|
// Initialize Chart.js
|
|
function initChart(): Chart {
|
|
const { textColor, gridColor } = getChartColors();
|
|
|
|
return new Chart(chartCanvas, {
|
|
type: "bubble",
|
|
data: {
|
|
datasets: buildDatasets(),
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: {
|
|
duration: 300,
|
|
},
|
|
interaction: {
|
|
intersect: true,
|
|
mode: "nearest",
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Using custom legend
|
|
},
|
|
tooltip: {
|
|
enabled: true,
|
|
callbacks: {
|
|
label: (context) => {
|
|
const point = context.raw as { customer: Customer };
|
|
const c = point.customer;
|
|
return [
|
|
c.name,
|
|
`${METRIC_LABELS[state.xAxis]}: ${formatValue(c[state.xAxis] as number, state.xAxis)}`,
|
|
`${METRIC_LABELS[state.yAxis]}: ${formatValue(c[state.yAxis] as number, state.yAxis)}`,
|
|
];
|
|
},
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: METRIC_LABELS[state.xAxis],
|
|
color: textColor,
|
|
font: { size: 11, weight: "bold" },
|
|
},
|
|
ticks: {
|
|
color: textColor,
|
|
font: { size: 10 },
|
|
callback: (value) => formatValue(value as number, state.xAxis),
|
|
},
|
|
grid: {
|
|
color: gridColor,
|
|
},
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: METRIC_LABELS[state.yAxis],
|
|
color: textColor,
|
|
font: { size: 11, weight: "bold" },
|
|
},
|
|
ticks: {
|
|
color: textColor,
|
|
font: { size: 10 },
|
|
callback: (value) => formatValue(value as number, state.yAxis),
|
|
},
|
|
grid: {
|
|
color: gridColor,
|
|
},
|
|
},
|
|
},
|
|
onClick: (_event, elements) => {
|
|
if (elements.length > 0) {
|
|
const element = elements[0];
|
|
const dataset = state.chart!.data.datasets[element.datasetIndex];
|
|
const point = dataset.data[element.index] as unknown as {
|
|
customer: Customer;
|
|
};
|
|
state.selectedCustomer = point.customer;
|
|
updateDetailPanel(point.customer);
|
|
}
|
|
},
|
|
onHover: (_event, elements) => {
|
|
if (elements.length > 0 && !state.selectedCustomer) {
|
|
const element = elements[0];
|
|
const dataset = state.chart!.data.datasets[element.datasetIndex];
|
|
const point = dataset.data[element.index] as unknown as {
|
|
customer: Customer;
|
|
};
|
|
updateDetailPanel(point.customer);
|
|
} else if (elements.length === 0 && !state.selectedCustomer) {
|
|
resetDetailPanel();
|
|
}
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Update chart with new data
|
|
function updateChart(): void {
|
|
if (!state.chart) return;
|
|
|
|
const { textColor, gridColor } = getChartColors();
|
|
|
|
state.chart.data.datasets = buildDatasets();
|
|
|
|
// Update axis titles and formatters (using type assertions for Chart.js scale options)
|
|
const scales = state.chart.options.scales as {
|
|
x: {
|
|
title: { text: string; color: string };
|
|
ticks: { color: string; callback: (value: number) => string };
|
|
grid: { color: string };
|
|
};
|
|
y: {
|
|
title: { text: string; color: string };
|
|
ticks: { color: string; callback: (value: number) => string };
|
|
grid: { color: string };
|
|
};
|
|
};
|
|
|
|
scales.x.title.text = METRIC_LABELS[state.xAxis];
|
|
scales.y.title.text = METRIC_LABELS[state.yAxis];
|
|
scales.x.title.color = textColor;
|
|
scales.y.title.color = textColor;
|
|
scales.x.ticks.color = textColor;
|
|
scales.y.ticks.color = textColor;
|
|
scales.x.ticks.callback = (value: number) => formatValue(value, state.xAxis);
|
|
scales.y.ticks.callback = (value: number) => formatValue(value, state.yAxis);
|
|
scales.x.grid.color = gridColor;
|
|
scales.y.grid.color = gridColor;
|
|
|
|
state.chart.update();
|
|
}
|
|
|
|
// Render custom legend
|
|
function renderLegend(): void {
|
|
// Count customers per segment
|
|
const counts = new Map<string, number>();
|
|
for (const c of state.customers) {
|
|
counts.set(c.segment, (counts.get(c.segment) || 0) + 1);
|
|
}
|
|
|
|
legendContainer.innerHTML = state.segments
|
|
.map((seg) => {
|
|
const count = counts.get(seg.name) || 0;
|
|
const isHidden = state.hiddenSegments.has(seg.name);
|
|
return `
|
|
<div class="legend-item ${isHidden ? "hidden" : ""}" data-segment="${seg.name}">
|
|
<span class="legend-dot" style="background: ${seg.color}"></span>
|
|
<span class="legend-label">${seg.name}</span>
|
|
<span class="legend-count">(${count})</span>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
// Add click handlers
|
|
legendContainer.querySelectorAll(".legend-item").forEach((item) => {
|
|
item.addEventListener("click", () => {
|
|
const segment = item.getAttribute("data-segment")!;
|
|
if (state.hiddenSegments.has(segment)) {
|
|
state.hiddenSegments.delete(segment);
|
|
} else {
|
|
state.hiddenSegments.add(segment);
|
|
}
|
|
renderLegend();
|
|
updateChart();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Update detail panel with customer info
|
|
function updateDetailPanel(customer: Customer): void {
|
|
const segmentClass = customer.segment.toLowerCase().replace("-", "-");
|
|
detailPanel.innerHTML = `
|
|
<span class="detail-name">${customer.name}</span>
|
|
<span class="detail-segment ${segmentClass}">${customer.segment}</span>
|
|
<span class="detail-metric"><strong>${formatValue(customer.annualRevenue, "annualRevenue")}</strong> rev</span>
|
|
<span class="detail-metric"><strong>${customer.engagementScore}</strong> engagement</span>
|
|
<span class="detail-metric"><strong>${customer.nps >= 0 ? "+" : ""}${customer.nps}</strong> NPS</span>
|
|
`;
|
|
}
|
|
|
|
// Reset detail panel to placeholder
|
|
function resetDetailPanel(): void {
|
|
detailPanel.innerHTML =
|
|
'<span class="detail-placeholder">Hover over a point to see details</span>';
|
|
}
|
|
|
|
// Create app instance
|
|
const app = new App({ name: "Customer Segmentation", version: "1.0.0" });
|
|
|
|
// Fetch data from server
|
|
async function fetchData(): Promise<void> {
|
|
try {
|
|
const result = await app.callServerTool({
|
|
name: "get-customer-data",
|
|
arguments: {},
|
|
});
|
|
|
|
const text = result
|
|
.content!.filter(
|
|
(c): c is { type: "text"; text: string } => c.type === "text",
|
|
)
|
|
.map((c) => c.text)
|
|
.join("");
|
|
const data = JSON.parse(text) as {
|
|
customers: Customer[];
|
|
segments: SegmentSummary[];
|
|
};
|
|
|
|
state.customers = data.customers;
|
|
state.segments = data.segments;
|
|
|
|
// Initialize or update chart
|
|
if (!state.chart) {
|
|
state.chart = initChart();
|
|
} else {
|
|
updateChart();
|
|
}
|
|
|
|
renderLegend();
|
|
log.info(`Loaded ${data.customers.length} customers`);
|
|
} catch (error) {
|
|
log.error("Failed to fetch data:", error);
|
|
}
|
|
}
|
|
|
|
// Event handlers
|
|
xAxisSelect.addEventListener("change", () => {
|
|
state.xAxis = xAxisSelect.value as MetricName;
|
|
updateChart();
|
|
});
|
|
|
|
yAxisSelect.addEventListener("change", () => {
|
|
state.yAxis = yAxisSelect.value as MetricName;
|
|
updateChart();
|
|
});
|
|
|
|
sizeMetricSelect.addEventListener("change", () => {
|
|
state.sizeMetric = sizeMetricSelect.value;
|
|
updateChart();
|
|
});
|
|
|
|
// Clear selection when clicking outside chart
|
|
document.addEventListener("click", (e) => {
|
|
if (!(e.target as HTMLElement).closest(".chart-section")) {
|
|
state.selectedCustomer = null;
|
|
resetDetailPanel();
|
|
}
|
|
});
|
|
|
|
// Handle system theme changes (fallback when host doesn't provide styles)
|
|
window
|
|
.matchMedia("(prefers-color-scheme: dark)")
|
|
.addEventListener("change", (e) => {
|
|
// Only apply if we haven't received host theme
|
|
if (!app.getHostContext()?.theme) {
|
|
applyDocumentTheme(e.matches ? "dark" : "light");
|
|
if (state.chart) {
|
|
state.chart.destroy();
|
|
state.chart = initChart();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Apply initial theme based on system preference (before host context is available)
|
|
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
applyDocumentTheme(systemDark ? "dark" : "light");
|
|
|
|
// Register handlers and connect
|
|
app.onerror = log.error;
|
|
|
|
// Handle host context changes (theme, styles, and fonts from host)
|
|
function handleHostContextChanged(ctx: McpUiHostContext) {
|
|
if (ctx.theme) {
|
|
applyDocumentTheme(ctx.theme);
|
|
}
|
|
if (ctx.styles?.variables) {
|
|
applyHostStyleVariables(ctx.styles.variables);
|
|
}
|
|
if (ctx.styles?.css?.fonts) {
|
|
applyHostFonts(ctx.styles.css.fonts);
|
|
}
|
|
if (ctx.safeAreaInsets) {
|
|
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
|
|
mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
|
|
mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
|
|
mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
|
|
}
|
|
// Recreate chart to pick up new colors
|
|
if (state.chart && (ctx.theme || ctx.styles?.variables)) {
|
|
state.chart.destroy();
|
|
state.chart = initChart();
|
|
}
|
|
}
|
|
|
|
app.onhostcontextchanged = handleHostContextChanged;
|
|
|
|
app.connect().then(() => {
|
|
const ctx = app.getHostContext();
|
|
if (ctx) {
|
|
handleHostContextChanged(ctx);
|
|
}
|
|
});
|
|
|
|
// Fetch data after connection
|
|
setTimeout(fetchData, 100);
|