Add system-monitor-server example
Demo MCP App showcasing real-time OS metrics visualization with: - Per-core CPU usage via stacked area chart (Chart.js) - Memory usage bar gauge with color-coded thresholds - Auto-polling at 2-second intervals - 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
f1c995fb99
commit
fa2d413f1d
3 changed files with 1075 additions and 0 deletions
12
examples/system-monitor-server/src/global.css
Normal file
12
examples/system-monitor-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;
|
||||
}
|
||||
233
examples/system-monitor-server/src/mcp-app.css
Normal file
233
examples/system-monitor-server/src/mcp-app.css
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-text: #1f2937;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #1d4ed8;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
--color-bar-bg: #e5e7eb;
|
||||
--color-card-bg: #f9fafb;
|
||||
--color-border: #e5e7eb;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #111827;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-muted: #9ca3af;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #60a5fa;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-danger: #f87171;
|
||||
--color-bar-bg: #374151;
|
||||
--color-card-bg: #1f2937;
|
||||
--color-border: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 16px;
|
||||
min-width: 60px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn.active:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
min-width: 8ch;
|
||||
font-family: ui-monospace, monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-indicator.polling {
|
||||
background: var(--color-success);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Section titles */
|
||||
.section-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Chart section */
|
||||
.chart-section {
|
||||
background: var(--color-card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
/* Memory section */
|
||||
.memory-section {
|
||||
background: var(--color-card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.memory-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memory-bar {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: var(--color-bar-bg);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--color-success);
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.memory-bar-fill.warning {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.memory-bar-fill.danger {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.memory-percent {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.memory-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Info section */
|
||||
.info-section {
|
||||
background: var(--color-card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-item dt {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.info-item dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
360
examples/system-monitor-server/src/mcp-app.ts
Normal file
360
examples/system-monitor-server/src/mcp-app.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
/**
|
||||
* @file System Monitor App - displays real-time OS metrics with Chart.js
|
||||
*/
|
||||
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import "./global.css";
|
||||
import "./mcp-app.css";
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables);
|
||||
|
||||
const log = {
|
||||
info: console.log.bind(console, "[APP]"),
|
||||
error: console.error.bind(console, "[APP]"),
|
||||
};
|
||||
|
||||
// Types for system stats response
|
||||
interface SystemStats {
|
||||
cpu: {
|
||||
cores: Array<{ idle: number; total: number }>;
|
||||
model: string;
|
||||
count: number;
|
||||
};
|
||||
memory: {
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
usedPercent: number;
|
||||
freeBytes: number;
|
||||
usedFormatted: string;
|
||||
totalFormatted: string;
|
||||
};
|
||||
system: {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
uptime: number;
|
||||
uptimeFormatted: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// DOM element references
|
||||
const pollToggleBtn = document.getElementById("poll-toggle-btn")!;
|
||||
const statusIndicator = document.getElementById("status-indicator")!;
|
||||
const statusText = document.getElementById("status-text")!;
|
||||
const cpuChartCanvas = document.getElementById(
|
||||
"cpu-chart",
|
||||
) as HTMLCanvasElement;
|
||||
const memoryBarFill = document.getElementById("memory-bar-fill")!;
|
||||
const memoryPercent = document.getElementById("memory-percent")!;
|
||||
const memoryDetail = document.getElementById("memory-detail")!;
|
||||
const infoHostname = document.getElementById("info-hostname")!;
|
||||
const infoPlatform = document.getElementById("info-platform")!;
|
||||
const infoUptime = document.getElementById("info-uptime")!;
|
||||
|
||||
// Polling state
|
||||
const HISTORY_LENGTH = 30;
|
||||
const POLL_INTERVAL = 2000;
|
||||
|
||||
interface PollingState {
|
||||
isPolling: boolean;
|
||||
intervalId: number | null;
|
||||
cpuHistory: number[][]; // [timestamp][coreIndex] = usage%
|
||||
labels: string[];
|
||||
coreCount: number;
|
||||
chart: Chart | null;
|
||||
previousCpuSnapshots: Array<{ idle: number; total: number }> | null;
|
||||
}
|
||||
|
||||
const state: PollingState = {
|
||||
isPolling: false,
|
||||
intervalId: null,
|
||||
cpuHistory: [],
|
||||
labels: [],
|
||||
coreCount: 0,
|
||||
chart: null,
|
||||
previousCpuSnapshots: null,
|
||||
};
|
||||
|
||||
// Color palette for CPU cores (distinct colors)
|
||||
const CORE_COLORS = [
|
||||
"rgba(59, 130, 246, 0.7)", // blue
|
||||
"rgba(16, 185, 129, 0.7)", // green
|
||||
"rgba(245, 158, 11, 0.7)", // amber
|
||||
"rgba(239, 68, 68, 0.7)", // red
|
||||
"rgba(139, 92, 246, 0.7)", // purple
|
||||
"rgba(236, 72, 153, 0.7)", // pink
|
||||
"rgba(20, 184, 166, 0.7)", // teal
|
||||
"rgba(249, 115, 22, 0.7)", // orange
|
||||
"rgba(34, 197, 94, 0.7)", // emerald
|
||||
"rgba(168, 85, 247, 0.7)", // violet
|
||||
"rgba(251, 146, 60, 0.7)", // orange-light
|
||||
"rgba(74, 222, 128, 0.7)", // green-light
|
||||
"rgba(96, 165, 250, 0.7)", // blue-light
|
||||
"rgba(248, 113, 113, 0.7)", // red-light
|
||||
"rgba(167, 139, 250, 0.7)", // purple-light
|
||||
"rgba(244, 114, 182, 0.7)", // pink-light
|
||||
];
|
||||
|
||||
// Calculate CPU usage percentages from raw timing data
|
||||
function calculateCpuUsage(
|
||||
current: Array<{ idle: number; total: number }>,
|
||||
previous: Array<{ idle: number; total: number }> | null,
|
||||
): number[] {
|
||||
if (!previous || previous.length !== current.length) {
|
||||
return current.map(() => 0);
|
||||
}
|
||||
return current.map((cur, i) => {
|
||||
const prev = previous[i];
|
||||
const idleDiff = cur.idle - prev.idle;
|
||||
const totalDiff = cur.total - prev.total;
|
||||
if (totalDiff === 0) return 0;
|
||||
return Math.round((1 - idleDiff / totalDiff) * 100);
|
||||
});
|
||||
}
|
||||
|
||||
function initChart(coreCount: number): Chart {
|
||||
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const textColor = isDarkMode ? "#9ca3af" : "#6b7280";
|
||||
const gridColor = isDarkMode ? "#374151" : "#e5e7eb";
|
||||
|
||||
const datasets = Array.from({ length: coreCount }, (_, i) => ({
|
||||
label: `P${i}`,
|
||||
data: [] as number[],
|
||||
fill: true,
|
||||
backgroundColor: CORE_COLORS[i % CORE_COLORS.length],
|
||||
borderColor: CORE_COLORS[i % CORE_COLORS.length].replace("0.7", "1"),
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
}));
|
||||
|
||||
return new Chart(cpuChartCanvas, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: [],
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 300,
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom",
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 8,
|
||||
font: { size: 10 },
|
||||
color: textColor,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
callbacks: {
|
||||
label: (context) =>
|
||||
`${context.dataset.label}: ${context.parsed.y}%`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: false,
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
min: 0,
|
||||
max: coreCount * 100,
|
||||
ticks: {
|
||||
callback: (value) => `${value}%`,
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
},
|
||||
grid: {
|
||||
color: gridColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateChart(cpuHistory: number[][], labels: string[]): void {
|
||||
if (!state.chart) return;
|
||||
|
||||
state.chart.data.labels = labels;
|
||||
|
||||
// Transpose: cpuHistory[time][core] -> datasets[core].data[time]
|
||||
for (let coreIdx = 0; coreIdx < state.coreCount; coreIdx++) {
|
||||
state.chart.data.datasets[coreIdx].data = cpuHistory.map(
|
||||
(snapshot) => snapshot[coreIdx] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamic y-axis scaling
|
||||
// Calculate max stacked value (sum of all cores at each time point)
|
||||
const stackedTotals = cpuHistory.map((snapshot) =>
|
||||
snapshot.reduce((sum, val) => sum + val, 0),
|
||||
);
|
||||
const currentMax = Math.max(...stackedTotals, 0);
|
||||
|
||||
// Add 20% headroom, clamp to reasonable bounds
|
||||
const headroom = 1.2;
|
||||
const minVisible = state.coreCount * 15; // At least 15% per core visible
|
||||
const absoluteMax = state.coreCount * 100;
|
||||
|
||||
const dynamicMax = Math.min(
|
||||
Math.max(currentMax * headroom, minVisible),
|
||||
absoluteMax,
|
||||
);
|
||||
|
||||
state.chart.options.scales!.y!.max = dynamicMax;
|
||||
|
||||
state.chart.update("none");
|
||||
}
|
||||
|
||||
function updateMemoryBar(memory: SystemStats["memory"]): void {
|
||||
const percent = memory.usedPercent;
|
||||
|
||||
memoryBarFill.style.width = `${percent}%`;
|
||||
memoryBarFill.classList.remove("warning", "danger");
|
||||
|
||||
if (percent >= 80) {
|
||||
memoryBarFill.classList.add("danger");
|
||||
} else if (percent >= 60) {
|
||||
memoryBarFill.classList.add("warning");
|
||||
}
|
||||
|
||||
memoryPercent.textContent = `${percent}%`;
|
||||
memoryDetail.textContent = `${memory.usedFormatted} / ${memory.totalFormatted}`;
|
||||
}
|
||||
|
||||
function updateSystemInfo(system: SystemStats["system"]): void {
|
||||
infoHostname.textContent = system.hostname;
|
||||
infoPlatform.textContent = system.platform;
|
||||
infoUptime.textContent = system.uptimeFormatted;
|
||||
}
|
||||
|
||||
function updateStatus(text: string, isPolling = false, isError = false): void {
|
||||
statusText.textContent = text;
|
||||
statusIndicator.classList.remove("polling", "error");
|
||||
|
||||
if (isError) {
|
||||
statusIndicator.classList.add("error");
|
||||
} else if (isPolling) {
|
||||
statusIndicator.classList.add("polling");
|
||||
}
|
||||
}
|
||||
|
||||
// Create app instance
|
||||
const app = new App({ name: "System Monitor", version: "1.0.0" });
|
||||
|
||||
async function fetchStats(): Promise<void> {
|
||||
try {
|
||||
const result = await app.callServerTool({
|
||||
name: "get-system-stats",
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
const stats = result.structuredContent as unknown as SystemStats;
|
||||
|
||||
// Initialize chart on first data if needed
|
||||
if (!state.chart && stats.cpu.count > 0) {
|
||||
state.coreCount = stats.cpu.count;
|
||||
state.chart = initChart(state.coreCount);
|
||||
}
|
||||
|
||||
// Calculate CPU usage from raw timing data (client-side)
|
||||
const coreUsages = calculateCpuUsage(
|
||||
stats.cpu.cores,
|
||||
state.previousCpuSnapshots,
|
||||
);
|
||||
state.previousCpuSnapshots = stats.cpu.cores;
|
||||
state.cpuHistory.push(coreUsages);
|
||||
state.labels.push(new Date().toLocaleTimeString());
|
||||
|
||||
// Trim to window size
|
||||
if (state.cpuHistory.length > HISTORY_LENGTH) {
|
||||
state.cpuHistory.shift();
|
||||
state.labels.shift();
|
||||
}
|
||||
|
||||
// Update UI
|
||||
updateChart(state.cpuHistory, state.labels);
|
||||
updateMemoryBar(stats.memory);
|
||||
updateSystemInfo(stats.system);
|
||||
|
||||
const time = new Date().toLocaleTimeString("en-US", { hour12: false });
|
||||
updateStatus(time, true);
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch stats:", error);
|
||||
updateStatus("Error", false, true);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (state.isPolling) return;
|
||||
|
||||
state.isPolling = true;
|
||||
pollToggleBtn.textContent = "Stop";
|
||||
pollToggleBtn.classList.add("active");
|
||||
updateStatus("Starting...", true);
|
||||
|
||||
// Immediate first fetch
|
||||
fetchStats();
|
||||
|
||||
// Start interval
|
||||
state.intervalId = window.setInterval(fetchStats, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (!state.isPolling) return;
|
||||
|
||||
state.isPolling = false;
|
||||
if (state.intervalId) {
|
||||
clearInterval(state.intervalId);
|
||||
state.intervalId = null;
|
||||
}
|
||||
|
||||
pollToggleBtn.textContent = "Start";
|
||||
pollToggleBtn.classList.remove("active");
|
||||
updateStatus("Stopped");
|
||||
}
|
||||
|
||||
function togglePolling(): void {
|
||||
if (state.isPolling) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
pollToggleBtn.addEventListener("click", togglePolling);
|
||||
|
||||
// Handle theme changes
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", () => {
|
||||
if (state.chart) {
|
||||
state.chart.destroy();
|
||||
state.chart = initChart(state.coreCount);
|
||||
updateChart(state.cpuHistory, state.labels);
|
||||
}
|
||||
});
|
||||
|
||||
// Register handlers and connect
|
||||
app.onerror = log.error;
|
||||
|
||||
app.connect(new PostMessageTransport(window.parent));
|
||||
|
||||
// Auto-start polling after a short delay
|
||||
setTimeout(startPolling, 500);
|
||||
Loading…
Reference in a new issue