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:
Jonathan Hefner 2025-12-03 14:27:16 -06:00
commit fa2d413f1d
3 changed files with 1075 additions and 0 deletions

View file

@ -0,0 +1,12 @@
* {
box-sizing: border-box;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 1rem;
}
code {
font-size: 1em;
}

View file

@ -0,0 +1,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;
}

View 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);