diff --git a/examples/customer-segmentation-server/README.md b/examples/customer-segmentation-server/README.md
new file mode 100644
index 0000000..bdcc03b
--- /dev/null
+++ b/examples/customer-segmentation-server/README.md
@@ -0,0 +1,58 @@
+# Example: Customer Segmentation Explorer
+
+A demo MCP App that displays customer data as an interactive scatter/bubble chart with segment-based clustering. Users can explore different metrics, filter by segment, and click to see detailed customer information.
+
+## Features
+
+- **Interactive Scatter Plot**: Bubble chart visualization using Chart.js with configurable X/Y axes
+- **Segment Clustering**: 250 customers grouped into 4 segments (Enterprise, Mid-Market, SMB, Startup)
+- **Axis Selection**: Choose from 6 metrics for each axis (Revenue, Employees, Account Age, Engagement, Tickets, NPS)
+- **Size Mapping**: Optional bubble sizing by a third metric for additional data dimension
+- **Legend Filtering**: Click segment pills to show/hide customer groups
+- **Detail Panel**: Hover or click customers to see name, segment, revenue, engagement, and NPS
+- **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-customer-data` tool that returns:
+
+- Array of 250 generated customer records with segment assignments
+- Segment summary with counts and colors for each group
+- Optional segment filter parameter
+
+The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
+
+### App (`src/mcp-app.ts`)
+
+- Uses Chart.js bubble chart for the visualization
+- Fetches data once on connection
+- Dropdown controls update chart axes and bubble sizing
+- Custom legend with clickable segment toggles
+- Detail panel updates on hover/click interactions
+
+### Data Generator (`src/data-generator.ts`)
+
+- Generates realistic customer data with Gaussian clustering around segment centers
+- Each segment has characteristic ranges for revenue, employees, engagement, etc.
+- Company names generated from word-list combinations (e.g., "Apex Data Corp")
+- Data cached in memory for session consistency
diff --git a/examples/customer-segmentation-server/mcp-app.html b/examples/customer-segmentation-server/mcp-app.html
new file mode 100644
index 0000000..10e584d
--- /dev/null
+++ b/examples/customer-segmentation-server/mcp-app.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+ Customer Segmentation Explorer
+
+
+
+
+
+
+
+ X:
+
+ Annual Revenue
+ Employees
+ Account Age (mo)
+ Engagement
+ Support Tickets
+ NPS
+
+
+
+ Y:
+
+ Annual Revenue
+ Employees
+ Account Age (mo)
+ Engagement
+ Support Tickets
+ NPS
+
+
+
+ Size:
+
+ Off
+ Annual Revenue
+ Employees
+ Account Age (mo)
+ Engagement
+ Support Tickets
+ NPS
+
+
+
+
+
+
+
+
+
+
+ Hover over a point to see details
+
+
+
+
+
+
+
diff --git a/examples/customer-segmentation-server/package.json b/examples/customer-segmentation-server/package.json
new file mode 100644
index 0000000..b23129b
--- /dev/null
+++ b/examples/customer-segmentation-server/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "customer-segmentation-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",
+ "zod": "^3.25.0"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.0.0",
+ "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/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts
new file mode 100644
index 0000000..4e29b74
--- /dev/null
+++ b/examples/customer-segmentation-server/server.ts
@@ -0,0 +1,169 @@
+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";
+import {
+ generateCustomers,
+ generateSegmentSummaries,
+} from "./src/data-generator.ts";
+import { SEGMENTS, type Customer, type SegmentSummary } from "./src/types.ts";
+
+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 GetCustomerDataInputSchema = z.object({
+ segment: z
+ .enum(["All", ...SEGMENTS])
+ .optional()
+ .describe("Filter by segment (default: All)"),
+});
+
+const CustomerSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ segment: z.string(),
+ annualRevenue: z.number(),
+ employeeCount: z.number(),
+ accountAge: z.number(),
+ engagementScore: z.number(),
+ supportTickets: z.number(),
+ nps: z.number(),
+});
+
+const SegmentSummarySchema = z.object({
+ name: z.string(),
+ count: z.number(),
+ color: z.string(),
+});
+
+const GetCustomerDataOutputSchema = z.object({
+ customers: z.array(CustomerSchema),
+ segments: z.array(SegmentSummarySchema),
+});
+
+// Cache generated data for session consistency
+let cachedCustomers: Customer[] | null = null;
+let cachedSegments: SegmentSummary[] | null = null;
+
+function getCustomerData(segmentFilter?: string): {
+ customers: Customer[];
+ segments: SegmentSummary[];
+} {
+ // Generate data on first call
+ if (!cachedCustomers) {
+ cachedCustomers = generateCustomers(250);
+ cachedSegments = generateSegmentSummaries(cachedCustomers);
+ }
+
+ // Filter by segment if specified
+ let customers = cachedCustomers;
+ if (segmentFilter && segmentFilter !== "All") {
+ customers = cachedCustomers.filter((c) => c.segment === segmentFilter);
+ }
+
+ return {
+ customers,
+ segments: cachedSegments!,
+ };
+}
+
+const server = new McpServer({
+ name: "Customer Segmentation Server",
+ version: "1.0.0",
+});
+
+// Register the get-customer-data tool and its associated UI resource
+{
+ const resourceUri = "ui://customer-segmentation/mcp-app.html";
+
+ server.registerTool(
+ "get-customer-data",
+ {
+ title: "Get Customer Data",
+ description:
+ "Returns customer data with segment information for visualization. Optionally filter by segment.",
+ inputSchema: GetCustomerDataInputSchema.shape,
+ outputSchema: GetCustomerDataOutputSchema.shape,
+ _meta: { [RESOURCE_URI_META_KEY]: resourceUri },
+ },
+ async ({ segment }): Promise => {
+ const data = getCustomerData(segment);
+
+ return {
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
+ structuredContent: data,
+ };
+ },
+ );
+
+ server.registerResource(
+ resourceUri,
+ resourceUri,
+ { description: "Customer Segmentation Explorer 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 }],
+ };
+ },
+ );
+}
+
+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(
+ `Customer Segmentation 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/customer-segmentation-server/src/data-generator.ts b/examples/customer-segmentation-server/src/data-generator.ts
new file mode 100644
index 0000000..10cfd06
--- /dev/null
+++ b/examples/customer-segmentation-server/src/data-generator.ts
@@ -0,0 +1,231 @@
+import type { Customer, SegmentSummary, SegmentName } from "./types.ts";
+import { SEGMENT_COLORS, SEGMENTS } from "./types.ts";
+
+// Company name generation
+const PREFIXES = [
+ "Apex",
+ "Nova",
+ "Prime",
+ "Vertex",
+ "Atlas",
+ "Quantum",
+ "Summit",
+ "Nexus",
+ "Titan",
+ "Pinnacle",
+ "Zenith",
+ "Vanguard",
+ "Horizon",
+ "Stellar",
+ "Onyx",
+ "Cobalt",
+ "Vector",
+ "Pulse",
+ "Forge",
+ "Spark",
+];
+
+const CORES = [
+ "Tech",
+ "Data",
+ "Cloud",
+ "Logic",
+ "Sync",
+ "Flow",
+ "Core",
+ "Net",
+ "Soft",
+ "Wave",
+ "Link",
+ "Mind",
+ "Byte",
+ "Grid",
+ "Hub",
+];
+
+const SUFFIXES = [
+ "Corp",
+ "Inc",
+ "Solutions",
+ "Systems",
+ "Labs",
+ "Group",
+ "Industries",
+ "Dynamics",
+ "Partners",
+ "Ventures",
+ "Global",
+ "Digital",
+];
+
+// Cluster centers for each segment
+interface ClusterCenter {
+ annualRevenue: { min: number; max: number };
+ employeeCount: { min: number; max: number };
+ accountAge: { min: number; max: number };
+ engagementScore: { min: number; max: number };
+ supportTickets: { min: number; max: number };
+ nps: { min: number; max: number };
+}
+
+const CLUSTER_CENTERS: Record = {
+ Enterprise: {
+ annualRevenue: { min: 2_000_000, max: 10_000_000 },
+ employeeCount: { min: 500, max: 5000 },
+ accountAge: { min: 60, max: 120 },
+ engagementScore: { min: 70, max: 95 },
+ supportTickets: { min: 5, max: 20 },
+ nps: { min: 40, max: 80 },
+ },
+ "Mid-Market": {
+ annualRevenue: { min: 500_000, max: 2_000_000 },
+ employeeCount: { min: 100, max: 500 },
+ accountAge: { min: 36, max: 84 },
+ engagementScore: { min: 60, max: 85 },
+ supportTickets: { min: 10, max: 30 },
+ nps: { min: 20, max: 60 },
+ },
+ SMB: {
+ annualRevenue: { min: 50_000, max: 500_000 },
+ employeeCount: { min: 10, max: 100 },
+ accountAge: { min: 12, max: 48 },
+ engagementScore: { min: 40, max: 70 },
+ supportTickets: { min: 15, max: 40 },
+ nps: { min: 0, max: 40 },
+ },
+ Startup: {
+ annualRevenue: { min: 10_000, max: 200_000 },
+ employeeCount: { min: 1, max: 50 },
+ accountAge: { min: 1, max: 24 },
+ engagementScore: { min: 50, max: 90 },
+ supportTickets: { min: 5, max: 25 },
+ nps: { min: 10, max: 70 },
+ },
+};
+
+// Segment distribution weights
+const SEGMENT_WEIGHTS: Record = {
+ Enterprise: 0.15,
+ "Mid-Market": 0.25,
+ SMB: 0.35,
+ Startup: 0.25,
+};
+
+// Box-Muller transform for Gaussian random numbers
+function gaussianRandom(mean: number, stdDev: number): number {
+ const u1 = Math.random();
+ const u2 = Math.random();
+ const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
+ return z0 * stdDev + mean;
+}
+
+// Generate a value within range with Gaussian distribution centered in range
+function generateClusteredValue(min: number, max: number): number {
+ const mean = (min + max) / 2;
+ const stdDev = (max - min) / 4; // 95% of values within range
+ const value = gaussianRandom(mean, stdDev);
+ return Math.max(min * 0.8, Math.min(max * 1.2, value)); // Allow slight overflow
+}
+
+// Generate unique company name
+function generateCompanyName(usedNames: Set): string {
+ let attempts = 0;
+ while (attempts < 100) {
+ const prefix = PREFIXES[Math.floor(Math.random() * PREFIXES.length)];
+ const core = CORES[Math.floor(Math.random() * CORES.length)];
+ const suffix = SUFFIXES[Math.floor(Math.random() * SUFFIXES.length)];
+ const name = `${prefix} ${core} ${suffix}`;
+ if (!usedNames.has(name)) {
+ usedNames.add(name);
+ return name;
+ }
+ attempts++;
+ }
+ // Fallback: add a number
+ const prefix = PREFIXES[Math.floor(Math.random() * PREFIXES.length)];
+ const core = CORES[Math.floor(Math.random() * CORES.length)];
+ const num = Math.floor(Math.random() * 1000);
+ return `${prefix} ${core} ${num}`;
+}
+
+// Select segment based on weights
+function selectSegment(): SegmentName {
+ const rand = Math.random();
+ let cumulative = 0;
+ for (const segment of SEGMENTS) {
+ cumulative += SEGMENT_WEIGHTS[segment];
+ if (rand < cumulative) {
+ return segment;
+ }
+ }
+ return "SMB";
+}
+
+// Generate a single customer
+function generateCustomer(id: number, usedNames: Set): Customer {
+ const segment = selectSegment();
+ const center = CLUSTER_CENTERS[segment];
+
+ return {
+ id: `cust-${id.toString().padStart(4, "0")}`,
+ name: generateCompanyName(usedNames),
+ segment,
+ annualRevenue: Math.round(
+ generateClusteredValue(
+ center.annualRevenue.min,
+ center.annualRevenue.max,
+ ),
+ ),
+ employeeCount: Math.round(
+ generateClusteredValue(
+ center.employeeCount.min,
+ center.employeeCount.max,
+ ),
+ ),
+ accountAge: Math.round(
+ generateClusteredValue(center.accountAge.min, center.accountAge.max),
+ ),
+ engagementScore: Math.round(
+ generateClusteredValue(
+ center.engagementScore.min,
+ center.engagementScore.max,
+ ),
+ ),
+ supportTickets: Math.round(
+ generateClusteredValue(
+ center.supportTickets.min,
+ center.supportTickets.max,
+ ),
+ ),
+ nps: Math.round(generateClusteredValue(center.nps.min, center.nps.max)),
+ };
+}
+
+// Generate all customers
+export function generateCustomers(count: number = 250): Customer[] {
+ const usedNames = new Set();
+ const customers: Customer[] = [];
+
+ for (let i = 0; i < count; i++) {
+ customers.push(generateCustomer(i + 1, usedNames));
+ }
+
+ return customers;
+}
+
+// Generate segment summaries from customers
+export function generateSegmentSummaries(
+ customers: Customer[],
+): SegmentSummary[] {
+ const counts = new Map();
+
+ for (const customer of customers) {
+ counts.set(customer.segment, (counts.get(customer.segment) || 0) + 1);
+ }
+
+ return SEGMENTS.map((segment) => ({
+ name: segment,
+ count: counts.get(segment) || 0,
+ color: SEGMENT_COLORS[segment],
+ }));
+}
diff --git a/examples/customer-segmentation-server/src/global.css b/examples/customer-segmentation-server/src/global.css
new file mode 100644
index 0000000..97cda44
--- /dev/null
+++ b/examples/customer-segmentation-server/src/global.css
@@ -0,0 +1,12 @@
+* {
+ box-sizing: border-box;
+}
+
+html, body {
+ font-family: system-ui, -apple-system, sans-serif;
+ font-size: 1rem;
+}
+
+code {
+ font-size: 1em;
+}
diff --git a/examples/customer-segmentation-server/src/mcp-app.css b/examples/customer-segmentation-server/src/mcp-app.css
new file mode 100644
index 0000000..0158c34
--- /dev/null
+++ b/examples/customer-segmentation-server/src/mcp-app.css
@@ -0,0 +1,224 @@
+:root {
+ --color-bg: #ffffff;
+ --color-text: #1f2937;
+ --color-text-muted: #6b7280;
+ --color-primary: #2563eb;
+ --color-primary-hover: #1d4ed8;
+ --color-card-bg: #f9fafb;
+ --color-border: #e5e7eb;
+ --color-enterprise: #1e40af;
+ --color-midmarket: #0d9488;
+ --color-smb: #059669;
+ --color-startup: #6366f1;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-bg: #111827;
+ --color-text: #f9fafb;
+ --color-text-muted: #9ca3af;
+ --color-primary: #3b82f6;
+ --color-primary-hover: #60a5fa;
+ --color-card-bg: #1f2937;
+ --color-border: #374151;
+ --color-enterprise: #3b82f6;
+ --color-midmarket: #14b8a6;
+ --color-smb: #10b981;
+ --color-startup: #818cf8;
+ }
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--color-bg);
+ color: var(--color-text);
+ overflow: hidden;
+}
+
+.main {
+ width: 600px;
+ height: 600px;
+ margin: 0 auto;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ overflow: hidden;
+}
+
+/* Header - ~40px */
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ height: 36px;
+ flex-shrink: 0;
+}
+
+.title {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.header-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Controls section - ~36px */
+.controls-section {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ height: 32px;
+ flex-shrink: 0;
+}
+
+.select-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-muted);
+}
+
+.select {
+ padding: 4px 8px;
+ font-size: 0.8125rem;
+ border: 1px solid var(--color-border);
+ border-radius: 4px;
+ background: var(--color-card-bg);
+ color: var(--color-text);
+ cursor: pointer;
+}
+
+.select:focus {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 1px;
+}
+
+/* Chart section - ~420px */
+.chart-section {
+ flex: 1;
+ min-height: 0;
+ background: var(--color-card-bg);
+ border-radius: 8px;
+ padding: 8px;
+ border: 1px solid var(--color-border);
+}
+
+.chart-container {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+
+/* Legend section - ~36px */
+.legend-section {
+ height: 32px;
+ flex-shrink: 0;
+}
+
+.legend {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ height: 100%;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ cursor: pointer;
+ padding: 4px 10px;
+ border-radius: 16px;
+ border: 1px solid var(--color-border);
+ background: var(--color-card-bg);
+ transition: all 0.15s ease;
+}
+
+.legend-item:hover {
+ border-color: var(--color-text-muted);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.legend-item.hidden {
+ opacity: 0.5;
+ background: transparent;
+}
+
+.legend-item.hidden .legend-label {
+ text-decoration: line-through;
+}
+
+.legend-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+}
+
+.legend-label {
+ font-weight: 500;
+}
+
+.legend-count {
+ color: var(--color-text-muted);
+}
+
+/* Detail section - ~44px */
+.detail-section {
+ height: 40px;
+ flex-shrink: 0;
+}
+
+.detail-panel {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ height: 100%;
+ background: var(--color-card-bg);
+ border-radius: 6px;
+ padding: 0 12px;
+ border: 1px solid var(--color-border);
+ font-size: 0.8125rem;
+}
+
+.detail-placeholder {
+ color: var(--color-text-muted);
+}
+
+.detail-name {
+ font-weight: 600;
+}
+
+.detail-segment {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: white;
+}
+
+.detail-segment.enterprise { background: var(--color-enterprise); }
+.detail-segment.mid-market { background: var(--color-midmarket); }
+.detail-segment.smb { background: var(--color-smb); }
+.detail-segment.startup { background: var(--color-startup); }
+
+.detail-metric {
+ color: var(--color-text-muted);
+}
+
+.detail-metric strong {
+ color: var(--color-text);
+ font-weight: 600;
+}
diff --git a/examples/customer-segmentation-server/src/mcp-app.ts b/examples/customer-segmentation-server/src/mcp-app.ts
new file mode 100644
index 0000000..3d9b2f7
--- /dev/null
+++ b/examples/customer-segmentation-server/src/mcp-app.ts
@@ -0,0 +1,401 @@
+/**
+ * @file Customer Segmentation Explorer - interactive scatter/bubble visualization
+ */
+import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
+import { Chart, registerables } from "chart.js";
+import "./global.css";
+import "./mcp-app.css";
+import type { Customer, SegmentSummary, MetricName } from "./types.ts";
+import { SEGMENT_COLORS, METRIC_LABELS } from "./types.ts";
+
+// 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 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;
+ 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,
+ };
+ });
+}
+
+// Initialize Chart.js
+function initChart(): Chart {
+ const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ const textColor = isDarkMode ? "#9ca3af" : "#6b7280";
+ const gridColor = isDarkMode ? "#374151" : "#e5e7eb";
+
+ 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 isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ const textColor = isDarkMode ? "#9ca3af" : "#6b7280";
+
+ 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: { callback: (value: number) => string };
+ };
+ y: {
+ title: { text: string; color: string };
+ ticks: { callback: (value: number) => 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.callback = (value: number) => formatValue(value, state.xAxis);
+ scales.y.ticks.callback = (value: number) => formatValue(value, state.yAxis);
+
+ state.chart.update();
+}
+
+// Render custom legend
+function renderLegend(): void {
+ // Count customers per segment
+ const counts = new Map();
+ 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 `
+
+
+ ${seg.name}
+ (${count})
+
+ `;
+ })
+ .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 = `
+ ${customer.name}
+ ${customer.segment}
+ ${formatValue(customer.annualRevenue, "annualRevenue")} rev
+ ${customer.engagementScore} engagement
+ ${customer.nps >= 0 ? "+" : ""}${customer.nps} NPS
+ `;
+}
+
+// Reset detail panel to placeholder
+function resetDetailPanel(): void {
+ detailPanel.innerHTML =
+ 'Hover over a point to see details ';
+}
+
+// Create app instance
+const app = new App({ name: "Customer Segmentation", version: "1.0.0" });
+
+// Fetch data from server
+async function fetchData(): Promise {
+ try {
+ const result = await app.callServerTool({
+ name: "get-customer-data",
+ arguments: {},
+ });
+
+ const data = result.structuredContent as unknown 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 theme changes
+window
+ .matchMedia("(prefers-color-scheme: dark)")
+ .addEventListener("change", () => {
+ if (state.chart) {
+ state.chart.destroy();
+ state.chart = initChart();
+ }
+ });
+
+// Register handlers and connect
+app.onerror = log.error;
+
+app.connect(new PostMessageTransport(window.parent));
+
+// Fetch data after connection
+setTimeout(fetchData, 100);
diff --git a/examples/customer-segmentation-server/src/types.ts b/examples/customer-segmentation-server/src/types.ts
new file mode 100644
index 0000000..daaa56c
--- /dev/null
+++ b/examples/customer-segmentation-server/src/types.ts
@@ -0,0 +1,46 @@
+export interface Customer {
+ id: string;
+ name: string;
+ segment: string;
+ annualRevenue: number;
+ employeeCount: number;
+ accountAge: number;
+ engagementScore: number;
+ supportTickets: number;
+ nps: number;
+}
+
+export interface SegmentSummary {
+ name: string;
+ count: number;
+ color: string;
+}
+
+export const SEGMENT_COLORS: Record = {
+ Enterprise: "#1e40af",
+ "Mid-Market": "#0d9488",
+ SMB: "#059669",
+ Startup: "#6366f1",
+};
+
+export const SEGMENTS = ["Enterprise", "Mid-Market", "SMB", "Startup"] as const;
+export type SegmentName = (typeof SEGMENTS)[number];
+
+export const METRIC_LABELS: Record = {
+ annualRevenue: "Annual Revenue",
+ employeeCount: "Employees",
+ accountAge: "Account Age (mo)",
+ engagementScore: "Engagement",
+ supportTickets: "Support Tickets",
+ nps: "NPS",
+};
+
+export const METRICS = [
+ "annualRevenue",
+ "employeeCount",
+ "accountAge",
+ "engagementScore",
+ "supportTickets",
+ "nps",
+] as const;
+export type MetricName = (typeof METRICS)[number];
diff --git a/examples/customer-segmentation-server/tsconfig.json b/examples/customer-segmentation-server/tsconfig.json
new file mode 100644
index 0000000..535267b
--- /dev/null
+++ b/examples/customer-segmentation-server/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src", "server.ts"]
+}
diff --git a/examples/customer-segmentation-server/vite.config.ts b/examples/customer-segmentation-server/vite.config.ts
new file mode 100644
index 0000000..6ff6d99
--- /dev/null
+++ b/examples/customer-segmentation-server/vite.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "vite";
+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: [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 a724462..a393d34 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -109,57 +109,35 @@
"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/budget-allocator-server": {
+ "extraneous": true
},
- "examples/simple-server": {
+ "examples/cohort-heatmap-server": {
+ "extraneous": true
+ },
+ "examples/customer-segmentation-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",
+ "chart.js": "^4.4.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.7.2",
+ "typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
},
+ "examples/scenario-modeler-server": {
+ "extraneous": true
+ },
"examples/system-monitor-server": {
"version": "1.0.0",
"dependencies": {
@@ -494,8 +472,6 @@
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
- "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
- "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/ext-apps": {
@@ -751,8 +727,6 @@
},
"node_modules/@types/bun": {
"version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.3.tgz",
- "integrity": "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -778,8 +752,6 @@
},
"node_modules/@types/cors": {
"version": "2.8.19",
- "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
- "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1085,8 +1057,6 @@
},
"node_modules/anymatch": {
"version": "3.1.3",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
- "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1099,8 +1069,6 @@
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1146,8 +1114,6 @@
},
"node_modules/binary-extensions": {
"version": "2.3.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
- "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1263,8 +1229,6 @@
},
"node_modules/bun-types": {
"version": "1.3.3",
- "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz",
- "integrity": "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1373,8 +1337,6 @@
},
"node_modules/chart.js": {
"version": "4.5.1",
- "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
- "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
@@ -1393,8 +1355,6 @@
},
"node_modules/chokidar": {
"version": "3.6.0",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
- "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1447,8 +1407,6 @@
},
"node_modules/concat-map": {
"version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
@@ -1540,6 +1498,10 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/customer-segmentation-server": {
+ "resolved": "examples/customer-segmentation-server",
+ "link": true
+ },
"node_modules/debug": {
"version": "4.4.3",
"license": "MIT",
@@ -1862,21 +1824,6 @@
"node": ">= 0.8"
}
},
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
@@ -1935,8 +1882,6 @@
},
"node_modules/glob-parent": {
"version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -2032,8 +1977,6 @@
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
- "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
- "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true,
"license": "ISC"
},
@@ -2050,8 +1993,6 @@
},
"node_modules/is-binary-path": {
"version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2063,8 +2004,6 @@
},
"node_modules/is-extglob": {
"version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2081,8 +2020,6 @@
},
"node_modules/is-glob": {
"version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2311,8 +2248,6 @@
},
"node_modules/nodemon": {
"version": "3.1.11",
- "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
- "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2340,8 +2275,6 @@
},
"node_modules/nodemon/node_modules/brace-expansion": {
"version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2351,8 +2284,6 @@
},
"node_modules/nodemon/node_modules/has-flag": {
"version": "3.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
- "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2361,8 +2292,6 @@
},
"node_modules/nodemon/node_modules/minimatch": {
"version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -2374,8 +2303,6 @@
},
"node_modules/nodemon/node_modules/semver": {
"version": "7.7.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -2387,8 +2314,6 @@
},
"node_modules/nodemon/node_modules/supports-color": {
"version": "5.5.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
- "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2400,8 +2325,6 @@
},
"node_modules/normalize-path": {
"version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2554,8 +2477,6 @@
},
"node_modules/pstree.remy": {
"version": "1.1.8",
- "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
- "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true,
"license": "MIT"
},
@@ -2627,8 +2548,6 @@
},
"node_modules/readdirp": {
"version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
- "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2640,8 +2559,6 @@
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2880,8 +2797,6 @@
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
- "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2893,8 +2808,6 @@
},
"node_modules/simple-update-notifier/node_modules/semver": {
"version": "7.7.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
@@ -2989,8 +2902,6 @@
},
"node_modules/systeminformation": {
"version": "5.27.11",
- "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.11.tgz",
- "integrity": "sha512-K3Lto/2m3K2twmKHdgx5B+0in9qhXK4YnoT9rIlgwN/4v7OV5c8IjbeAUkuky/6VzCQC7iKCAqi8rZathCdjHg==",
"license": "MIT",
"os": [
"darwin",
@@ -3082,8 +2993,6 @@
},
"node_modules/touch": {
"version": "3.1.1",
- "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
- "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -3156,8 +3065,6 @@
},
"node_modules/undefsafe": {
"version": "2.0.5",
- "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
- "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true,
"license": "MIT"
},