Add customer-segmentation-server example

Demo MCP App for customer data visualization featuring:
- Interactive bubble chart (Chart.js) with configurable X/Y axes
- 250 customers across 4 segments (Enterprise, Mid-Market, SMB, Startup)
- 6 metrics: Revenue, Employees, Account Age, Engagement, Tickets, NPS
- Optional bubble sizing by a third metric
- Clickable legend for segment filtering
- Detail panel on hover/click
- Light/dark theme support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2025-12-04 13:37:03 -06:00
commit 98d8fcddff
12 changed files with 1300 additions and 109 deletions

View file

@ -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

View file

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Customer Segmentation Explorer</title>
</head>
<body>
<main class="main">
<header class="header">
<h1 class="title">Customer Segmentation</h1>
</header>
<section class="controls-section">
<label class="select-label">
X:
<select id="x-axis" class="select">
<option value="annualRevenue">Annual Revenue</option>
<option value="employeeCount">Employees</option>
<option value="accountAge">Account Age (mo)</option>
<option value="engagementScore">Engagement</option>
<option value="supportTickets">Support Tickets</option>
<option value="nps">NPS</option>
</select>
</label>
<label class="select-label">
Y:
<select id="y-axis" class="select">
<option value="annualRevenue">Annual Revenue</option>
<option value="employeeCount">Employees</option>
<option value="accountAge">Account Age (mo)</option>
<option value="engagementScore" selected>Engagement</option>
<option value="supportTickets">Support Tickets</option>
<option value="nps">NPS</option>
</select>
</label>
<label class="select-label">
Size:
<select id="size-metric" class="select">
<option value="off">Off</option>
<option value="annualRevenue">Annual Revenue</option>
<option value="employeeCount">Employees</option>
<option value="accountAge">Account Age (mo)</option>
<option value="engagementScore">Engagement</option>
<option value="supportTickets">Support Tickets</option>
<option value="nps">NPS</option>
</select>
</label>
</section>
<section class="chart-section">
<div class="chart-container">
<canvas id="scatter-chart"></canvas>
</div>
</section>
<section class="legend-section">
<div id="legend" class="legend"></div>
</section>
<section class="detail-section">
<div id="detail-panel" class="detail-panel">
<span class="detail-placeholder">Hover over a point to see details</span>
</div>
</section>
</main>
<script type="module" src="./src/mcp-app.ts"></script>
</body>
</html>

View file

@ -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"
}
}

View file

@ -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<CallToolResult> => {
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<ReadResourceResult> => {
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);

View file

@ -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<SegmentName, ClusterCenter> = {
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<SegmentName, number> = {
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>): 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<string>): 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<string>();
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<string, number>();
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],
}));
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<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,
};
});
}
// 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<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 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);

View file

@ -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<string, string> = {
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<string, string> = {
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];

View file

@ -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"]
}

View file

@ -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,
},
});

125
package-lock.json generated
View file

@ -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"
},