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:
parent
fa2d413f1d
commit
98d8fcddff
12 changed files with 1300 additions and 109 deletions
58
examples/customer-segmentation-server/README.md
Normal file
58
examples/customer-segmentation-server/README.md
Normal 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
|
||||
70
examples/customer-segmentation-server/mcp-app.html
Normal file
70
examples/customer-segmentation-server/mcp-app.html
Normal 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>
|
||||
30
examples/customer-segmentation-server/package.json
Normal file
30
examples/customer-segmentation-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
169
examples/customer-segmentation-server/server.ts
Normal file
169
examples/customer-segmentation-server/server.ts
Normal 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);
|
||||
231
examples/customer-segmentation-server/src/data-generator.ts
Normal file
231
examples/customer-segmentation-server/src/data-generator.ts
Normal 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],
|
||||
}));
|
||||
}
|
||||
12
examples/customer-segmentation-server/src/global.css
Normal file
12
examples/customer-segmentation-server/src/global.css
Normal 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;
|
||||
}
|
||||
224
examples/customer-segmentation-server/src/mcp-app.css
Normal file
224
examples/customer-segmentation-server/src/mcp-app.css
Normal 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;
|
||||
}
|
||||
401
examples/customer-segmentation-server/src/mcp-app.ts
Normal file
401
examples/customer-segmentation-server/src/mcp-app.ts
Normal 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);
|
||||
46
examples/customer-segmentation-server/src/types.ts
Normal file
46
examples/customer-segmentation-server/src/types.ts
Normal 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];
|
||||
19
examples/customer-segmentation-server/tsconfig.json
Normal file
19
examples/customer-segmentation-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
24
examples/customer-segmentation-server/vite.config.ts
Normal file
24
examples/customer-segmentation-server/vite.config.ts
Normal 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
125
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue