ext-apps/examples/system-monitor-server/src/mcp-app.ts
Jonathan Hefner 9acebd7f9f
Separate static and dynamic data in system-monitor-server tools
The two-tool architecture previously had both tools returning identical
`SystemStats` data. This refactors them to properly separate concerns:

- `get-system-info` (Model-visible): Returns static system configuration
  (hostname, platform, CPU model/count, total memory) sent once via
  `ontoolresult`

- `poll-system-stats` (App-only): Returns dynamic metrics (per-core CPU
  timing, memory usage, uptime) polled every 2 seconds

Moves `formatBytes()` and `formatUptime()` utilities to the client since
the server now returns raw values for client-side formatting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:04:35 -06:00

458 lines
13 KiB
TypeScript

/**
* @file System Monitor App - displays real-time OS metrics with Chart.js
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { Chart, registerables } from "chart.js";
import "./global.css";
import "./mcp-app.css";
// Register Chart.js components
Chart.register(...registerables);
// =============================================================================
// Types
// =============================================================================
// Static system info (received once via ontoolresult from LLM-facing tool)
interface SystemInfo {
hostname: string;
platform: string;
arch: string;
cpu: {
model: string;
count: number;
};
memory: {
totalBytes: number;
};
}
// Dynamic polling data (received repeatedly via poll-system-stats)
interface PollStats {
cpu: {
cores: Array<{ idle: number; total: number }>;
};
memory: {
usedBytes: number;
usedPercent: number;
freeBytes: number;
};
uptime: {
seconds: number;
};
timestamp: string;
}
interface AppState {
// Static info (set once via ontoolresult)
systemInfo: SystemInfo | null;
// Polling state
isPolling: boolean;
intervalId: number | null;
cpuHistory: number[][]; // [timestamp][coreIndex] = usage%
labels: string[];
chart: Chart | null;
previousCpuSnapshots: Array<{ idle: number; total: number }> | null;
}
// =============================================================================
// DOM References
// =============================================================================
const mainEl = document.querySelector(".main") as HTMLElement;
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")!;
// =============================================================================
// Constants & State
// =============================================================================
const HISTORY_LENGTH = 30;
const POLL_INTERVAL = 2000;
// 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
];
const state: AppState = {
systemInfo: null,
isPolling: false,
intervalId: null,
cpuHistory: [],
labels: [],
chart: null,
previousCpuSnapshots: null,
};
// =============================================================================
// Formatting Utilities
// =============================================================================
function formatBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
return parts.length > 0 ? parts.join(" ") : "< 1m";
}
// =============================================================================
// Chart.js Setup
// =============================================================================
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[],
coreCount: number,
): void {
if (!state.chart) return;
state.chart.data.labels = labels;
// Transpose: cpuHistory[time][core] -> datasets[core].data[time]
for (let coreIdx = 0; coreIdx < 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 = coreCount * 15; // At least 15% per core visible
const absoluteMax = coreCount * 100;
const dynamicMax = Math.min(
Math.max(currentMax * headroom, minVisible),
absoluteMax,
);
state.chart.options.scales!.y!.max = dynamicMax;
state.chart.update("none");
}
// =============================================================================
// UI Updates
// =============================================================================
function updateMemoryBar(
memory: PollStats["memory"],
totalBytes: number,
): 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 = `${formatBytes(memory.usedBytes)} / ${formatBytes(totalBytes)}`;
}
// Called once when static system info is received
function updateStaticSystemInfo(info: SystemInfo): void {
infoHostname.textContent = info.hostname;
infoPlatform.textContent = info.platform;
}
// Called on each poll for dynamic uptime
function updateDynamicUptime(uptime: PollStats["uptime"]): void {
infoUptime.textContent = formatUptime(uptime.seconds);
}
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");
}
}
// =============================================================================
// MCP App & Data Fetching
// =============================================================================
const app = new App({ name: "System Monitor", version: "1.0.0" });
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);
});
}
async function fetchStats(): Promise<void> {
// systemInfo is guaranteed to exist (polling starts after ontoolresult)
const { systemInfo } = state;
if (!systemInfo) return;
try {
const result = await app.callServerTool({
name: "poll-system-stats", // App-only tool for polling dynamic data
arguments: {},
});
const stats = result.structuredContent as unknown as PollStats;
const coreCount = systemInfo.cpu.count;
// 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 with dynamic data
updateChart(state.cpuHistory, state.labels, coreCount);
updateMemoryBar(stats.memory, systemInfo.memory.totalBytes);
updateDynamicUptime(stats.uptime);
const time = new Date().toLocaleTimeString("en-US", { hour12: false });
updateStatus(time, true);
} catch (error) {
console.error("Failed to fetch stats:", error);
updateStatus("Error", false, true);
}
}
// =============================================================================
// Polling Control
// =============================================================================
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 Handlers & Initialization
// =============================================================================
pollToggleBtn.addEventListener("click", togglePolling);
// Handle theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (state.chart && state.systemInfo) {
const coreCount = state.systemInfo.cpu.count;
state.chart.destroy();
state.chart = initChart(coreCount);
updateChart(state.cpuHistory, state.labels, coreCount);
}
});
app.onerror = console.error;
// Receive static system info when host sends the initial tool result
app.ontoolresult = (result) => {
const info = result.structuredContent as unknown as SystemInfo;
if (info) {
state.systemInfo = info;
// Initialize chart with CPU count from static info
state.chart = initChart(info.cpu.count);
// Update static UI elements (hostname, platform)
updateStaticSystemInfo(info);
// Start polling after receiving static info
startPolling();
}
};
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.safeAreaInsets) {
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
}
app.onhostcontextchanged = handleHostContextChanged;
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});