From 98d8fcddff996a4733bb8be313ef477c847204ab Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Thu, 4 Dec 2025 13:37:03 -0600 Subject: [PATCH] Add customer-segmentation-server example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../customer-segmentation-server/README.md | 58 +++ .../customer-segmentation-server/mcp-app.html | 70 +++ .../customer-segmentation-server/package.json | 30 ++ .../customer-segmentation-server/server.ts | 169 ++++++++ .../src/data-generator.ts | 231 ++++++++++ .../src/global.css | 12 + .../src/mcp-app.css | 224 ++++++++++ .../src/mcp-app.ts | 401 ++++++++++++++++++ .../customer-segmentation-server/src/types.ts | 46 ++ .../tsconfig.json | 19 + .../vite.config.ts | 24 ++ package-lock.json | 125 +----- 12 files changed, 1300 insertions(+), 109 deletions(-) create mode 100644 examples/customer-segmentation-server/README.md create mode 100644 examples/customer-segmentation-server/mcp-app.html create mode 100644 examples/customer-segmentation-server/package.json create mode 100644 examples/customer-segmentation-server/server.ts create mode 100644 examples/customer-segmentation-server/src/data-generator.ts create mode 100644 examples/customer-segmentation-server/src/global.css create mode 100644 examples/customer-segmentation-server/src/mcp-app.css create mode 100644 examples/customer-segmentation-server/src/mcp-app.ts create mode 100644 examples/customer-segmentation-server/src/types.ts create mode 100644 examples/customer-segmentation-server/tsconfig.json create mode 100644 examples/customer-segmentation-server/vite.config.ts 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 + + +
+
+

Customer Segmentation

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