Merge pull request #388 from jonathanhefner/system-monitor-static-vs-dynamic

Separate static and dynamic data in `system-monitor-server` tools
This commit is contained in:
Jonathan Hefner 2026-01-28 12:41:38 -06:00 • committed by GitHub
commit 55f0ef0198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 206 additions and 149 deletions

View file

@ -79,18 +79,24 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w
### Server (`server.ts`) ### Server (`server.ts`)
Exposes a single `get-system-stats` tool that returns: Exposes two tools demonstrating a polling pattern:
- Raw per-core CPU timing data (idle/total counters) 1. **`get-system-info`** (Model-visible) — Returns static system configuration:
- Memory usage (used/total/percentage) - Hostname, platform, architecture
- System info (hostname, platform, uptime) - CPU model and core count
- Total memory
The tool is linked to a UI resource via `_meta.ui.resourceUri`. 2. **`poll-system-stats`** (App-only, `visibility: ["app"]`) — Returns dynamic metrics:
- Per-core CPU timing data (idle/total counters)
- Memory usage (used/free/percentage)
- Uptime
The Model-visible tool is linked to a UI resource via `_meta.ui.resourceUri`.
### App (`src/mcp-app.ts`) ### App (`src/mcp-app.ts`)
- Receives static system info via `ontoolresult` when the host sends the `get-system-info` result
- Polls `poll-system-stats` every 2 seconds for dynamic metrics
- Uses Chart.js for the stacked area chart visualization - Uses Chart.js for the stacked area chart visualization
- Polls the server tool every 2 seconds
- Computes CPU usage percentages client-side from timing deltas - Computes CPU usage percentages client-side from timing deltas
- Maintains a 30-point history (1 minute at 2s intervals) - Maintains a 30-point history (1 minute at 2s intervals)
- Updates all UI elements on each poll

View file

@ -13,50 +13,78 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import si from "systeminformation"; import si from "systeminformation";
import { z } from "zod"; import { z } from "zod";
// Schemas - types are derived from these using z.infer
const CpuCoreSchema = z.object({
idle: z.number(),
total: z.number(),
});
const CpuStatsSchema = z.object({ // Works both from source (server.ts) and compiled (dist/server.js)
cores: z.array(CpuCoreSchema), const DIST_DIR = import.meta.filename.endsWith(".ts")
model: z.string(), ? path.join(import.meta.dirname, "dist")
count: z.number(), : import.meta.dirname;
});
const MemoryStatsSchema = z.object({ // =============================================================================
usedBytes: z.number(), // Types and schemas
totalBytes: z.number(), // =============================================================================
usedPercent: z.number(),
freeBytes: z.number(),
usedFormatted: z.string(),
totalFormatted: z.string(),
});
const SystemInfoSchema = z.object({ const SystemInfoSchema = z.object({
hostname: z.string(), hostname: z.string(),
platform: z.string(), platform: z.string(),
arch: z.string(), arch: z.string(),
uptime: z.number(), cpu: z.object({
uptimeFormatted: z.string(), model: z.string(),
count: z.number(),
}),
memory: z.object({
totalBytes: z.number(),
}),
}); });
const SystemStatsSchema = z.object({ type SystemInfo = z.infer<typeof SystemInfoSchema>;
cpu: CpuStatsSchema,
memory: MemoryStatsSchema, const CpuCoreSchema = z.object({
system: SystemInfoSchema, idle: z.number(),
total: z.number(),
});
type CpuCore = z.infer<typeof CpuCoreSchema>;
const PollStatsSchema = z.object({
cpu: z.object({
cores: z.array(CpuCoreSchema),
}),
memory: z.object({
usedBytes: z.number(),
usedPercent: z.number(),
freeBytes: z.number(),
}),
uptime: z.object({
seconds: z.number(),
}),
timestamp: z.string(), timestamp: z.string(),
}); });
// Types derived from schemas type PollStats = z.infer<typeof PollStatsSchema>;
type CpuCore = z.infer<typeof CpuCoreSchema>;
type MemoryStats = z.infer<typeof MemoryStatsSchema>; // =============================================================================
type SystemStats = z.infer<typeof SystemStatsSchema>; // Static system info (called once by Model-facing tool)
// Works both from source (server.ts) and compiled (dist/server.js) // =============================================================================
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist") function getSystemInfo(): SystemInfo {
: import.meta.dirname; const cpuInfo = os.cpus()[0];
return {
hostname: os.hostname(),
platform: `${os.platform()} ${os.arch()}`,
arch: os.arch(),
cpu: {
model: cpuInfo?.model ?? "Unknown",
count: os.cpus().length,
},
memory: {
totalBytes: os.totalmem(),
},
};
}
// =============================================================================
// Dynamic polling stats (called repeatedly by app-only tool)
// =============================================================================
// Returns raw CPU timing data per core (client calculates usage from deltas) // Returns raw CPU timing data per core (client calculates usage from deltas)
function getCpuSnapshots(): CpuCore[] { function getCpuSnapshots(): CpuCore[] {
@ -67,111 +95,73 @@ function getCpuSnapshots(): CpuCore[] {
return { idle, total }; return { idle, total };
}); });
} }
async function getPollStats(): Promise<PollStats> {
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";
}
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]}`;
}
async function getMemoryStats(): Promise<MemoryStats> {
const mem = await si.mem(); const mem = await si.mem();
return {
usedBytes: mem.active,
totalBytes: mem.total,
usedPercent: Math.round((mem.active / mem.total) * 100),
freeBytes: mem.available,
usedFormatted: formatBytes(mem.active),
totalFormatted: formatBytes(mem.total),
};
}
async function getStats(): Promise<SystemStats> {
const cpuSnapshots = getCpuSnapshots();
const cpuInfo = os.cpus()[0];
const memory = await getMemoryStats();
const uptimeSeconds = os.uptime(); const uptimeSeconds = os.uptime();
return { return {
cpu: { cpu: {
cores: cpuSnapshots, cores: getCpuSnapshots(),
model: cpuInfo?.model ?? "Unknown",
count: os.cpus().length,
}, },
memory, memory: {
system: { usedBytes: mem.active,
hostname: os.hostname(), usedPercent: Math.round((mem.active / mem.total) * 100),
platform: `${os.platform()} ${os.arch()}`, freeBytes: mem.available,
arch: os.arch(), },
uptime: uptimeSeconds, uptime: {
uptimeFormatted: formatUptime(uptimeSeconds), seconds: uptimeSeconds,
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }
// =============================================================================
// MCP server
// =============================================================================
export function createServer(): McpServer { export function createServer(): McpServer {
const server = new McpServer({ const server = new McpServer({
name: "System Monitor Server", name: "System Monitor Server",
version: "1.0.0", version: "1.0.0",
}); });
// Register the get-system-stats tool and its associated UI resource
const resourceUri = "ui://system-monitor/mcp-app.html"; const resourceUri = "ui://system-monitor/mcp-app.html";
// Model-facing tool: returns static system configuration
registerAppTool( registerAppTool(
server, server,
"get-system-stats", "get-system-info",
{ {
title: "Get System Stats", title: "Get System Info",
description: description:
"Returns current system statistics including per-core CPU usage, memory, and system info.", "Returns system information, including hostname, platform, CPU info, and memory.",
inputSchema: {}, inputSchema: {},
outputSchema: SystemStatsSchema.shape, outputSchema: SystemInfoSchema.shape,
_meta: { ui: { resourceUri } }, _meta: { ui: { resourceUri } },
}, },
async (): Promise<CallToolResult> => { (): CallToolResult => {
const stats = await getStats(); const info = getSystemInfo();
return { return {
content: [{ type: "text", text: JSON.stringify(stats) }], content: [{ type: "text", text: JSON.stringify(info) }],
structuredContent: stats, structuredContent: info,
}; };
}, },
); );
// App-only tool for polling - used by the UI for periodic refresh // App-only tool: returns dynamic metrics for polling
registerAppTool( registerAppTool(
server, server,
"refresh-stats", "poll-system-stats",
{ {
title: "Refresh Stats", title: "Poll System Stats",
description: "Refresh system statistics (app-only, for polling)", description:
"Returns dynamic system metrics for polling: per-core CPU timing, memory usage, and uptime. App-only.",
inputSchema: {}, inputSchema: {},
outputSchema: SystemStatsSchema.shape, outputSchema: PollStatsSchema.shape,
_meta: { ui: { visibility: ["app"] } }, _meta: { ui: { visibility: ["app"] } },
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
const stats = await getStats(); const stats = await getPollStats();
return { return {
content: [{ type: "text", text: JSON.stringify(stats) }], content: [{ type: "text", text: JSON.stringify(stats) }],
structuredContent: stats, structuredContent: stats,

View file

@ -13,36 +13,44 @@ Chart.register(...registerables);
// Types // Types
// ============================================================================= // =============================================================================
interface SystemStats { // Static system info (received once via ontoolresult from LLM-facing tool)
interface SystemInfo {
hostname: string;
platform: string;
arch: string;
cpu: { cpu: {
cores: Array<{ idle: number; total: number }>;
model: string; model: string;
count: number; count: number;
}; };
memory: { memory: {
usedBytes: number;
totalBytes: number; 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; usedPercent: number;
freeBytes: number; freeBytes: number;
usedFormatted: string;
totalFormatted: string;
}; };
system: { uptime: {
hostname: string; seconds: number;
platform: string;
arch: string;
uptime: number;
uptimeFormatted: string;
}; };
timestamp: string; timestamp: string;
} }
interface PollingState { interface AppState {
// Static info (set once via ontoolresult)
systemInfo: SystemInfo | null;
// Polling state
isPolling: boolean; isPolling: boolean;
intervalId: number | null; intervalId: number | null;
cpuHistory: number[][]; // [timestamp][coreIndex] = usage% cpuHistory: number[][]; // [timestamp][coreIndex] = usage%
labels: string[]; labels: string[];
coreCount: number;
chart: Chart | null; chart: Chart | null;
previousCpuSnapshots: Array<{ idle: number; total: number }> | null; previousCpuSnapshots: Array<{ idle: number; total: number }> | null;
} }
@ -92,16 +100,42 @@ const CORE_COLORS = [
"rgba(244, 114, 182, 0.7)", // pink-light "rgba(244, 114, 182, 0.7)", // pink-light
]; ];
const state: PollingState = { const state: AppState = {
systemInfo: null,
isPolling: false, isPolling: false,
intervalId: null, intervalId: null,
cpuHistory: [], cpuHistory: [],
labels: [], labels: [],
coreCount: 0,
chart: null, chart: null,
previousCpuSnapshots: 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 // Chart.js Setup
// ============================================================================= // =============================================================================
@ -179,13 +213,17 @@ function initChart(coreCount: number): Chart {
}); });
} }
function updateChart(cpuHistory: number[][], labels: string[]): void { function updateChart(
cpuHistory: number[][],
labels: string[],
coreCount: number,
): void {
if (!state.chart) return; if (!state.chart) return;
state.chart.data.labels = labels; state.chart.data.labels = labels;
// Transpose: cpuHistory[time][core] -> datasets[core].data[time] // Transpose: cpuHistory[time][core] -> datasets[core].data[time]
for (let coreIdx = 0; coreIdx < state.coreCount; coreIdx++) { for (let coreIdx = 0; coreIdx < coreCount; coreIdx++) {
state.chart.data.datasets[coreIdx].data = cpuHistory.map( state.chart.data.datasets[coreIdx].data = cpuHistory.map(
(snapshot) => snapshot[coreIdx] ?? 0, (snapshot) => snapshot[coreIdx] ?? 0,
); );
@ -200,8 +238,8 @@ function updateChart(cpuHistory: number[][], labels: string[]): void {
// Add 20% headroom, clamp to reasonable bounds // Add 20% headroom, clamp to reasonable bounds
const headroom = 1.2; const headroom = 1.2;
const minVisible = state.coreCount * 15; // At least 15% per core visible const minVisible = coreCount * 15; // At least 15% per core visible
const absoluteMax = state.coreCount * 100; const absoluteMax = coreCount * 100;
const dynamicMax = Math.min( const dynamicMax = Math.min(
Math.max(currentMax * headroom, minVisible), Math.max(currentMax * headroom, minVisible),
@ -217,7 +255,10 @@ function updateChart(cpuHistory: number[][], labels: string[]): void {
// UI Updates // UI Updates
// ============================================================================= // =============================================================================
function updateMemoryBar(memory: SystemStats["memory"]): void { function updateMemoryBar(
memory: PollStats["memory"],
totalBytes: number,
): void {
const percent = memory.usedPercent; const percent = memory.usedPercent;
memoryBarFill.style.width = `${percent}%`; memoryBarFill.style.width = `${percent}%`;
@ -230,13 +271,18 @@ function updateMemoryBar(memory: SystemStats["memory"]): void {
} }
memoryPercent.textContent = `${percent}%`; memoryPercent.textContent = `${percent}%`;
memoryDetail.textContent = `${memory.usedFormatted} / ${memory.totalFormatted}`; memoryDetail.textContent = `${formatBytes(memory.usedBytes)} / ${formatBytes(totalBytes)}`;
} }
function updateSystemInfo(system: SystemStats["system"]): void { // Called once when static system info is received
infoHostname.textContent = system.hostname; function updateStaticSystemInfo(info: SystemInfo): void {
infoPlatform.textContent = system.platform; infoHostname.textContent = info.hostname;
infoUptime.textContent = system.uptimeFormatted; 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 { function updateStatus(text: string, isPolling = false, isError = false): void {
@ -273,19 +319,18 @@ function calculateCpuUsage(
} }
async function fetchStats(): Promise<void> { async function fetchStats(): Promise<void> {
// systemInfo is guaranteed to exist (polling starts after ontoolresult)
const { systemInfo } = state;
if (!systemInfo) return;
try { try {
const result = await app.callServerTool({ const result = await app.callServerTool({
name: "refresh-stats", // Use app-only tool for polling name: "poll-system-stats", // App-only tool for polling dynamic data
arguments: {}, arguments: {},
}); });
const stats = result.structuredContent as unknown as SystemStats; const stats = result.structuredContent as unknown as PollStats;
const coreCount = systemInfo.cpu.count;
// 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) // Calculate CPU usage from raw timing data (client-side)
const coreUsages = calculateCpuUsage( const coreUsages = calculateCpuUsage(
@ -302,10 +347,10 @@ async function fetchStats(): Promise<void> {
state.labels.shift(); state.labels.shift();
} }
// Update UI // Update UI with dynamic data
updateChart(state.cpuHistory, state.labels); updateChart(state.cpuHistory, state.labels, coreCount);
updateMemoryBar(stats.memory); updateMemoryBar(stats.memory, systemInfo.memory.totalBytes);
updateSystemInfo(stats.system); updateDynamicUptime(stats.uptime);
const time = new Date().toLocaleTimeString("en-US", { hour12: false }); const time = new Date().toLocaleTimeString("en-US", { hour12: false });
updateStatus(time, true); updateStatus(time, true);
@ -366,15 +411,34 @@ pollToggleBtn.addEventListener("click", togglePolling);
window window
.matchMedia("(prefers-color-scheme: dark)") .matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => { .addEventListener("change", () => {
if (state.chart) { if (state.chart && state.systemInfo) {
const coreCount = state.systemInfo.cpu.count;
state.chart.destroy(); state.chart.destroy();
state.chart = initChart(state.coreCount); state.chart = initChart(coreCount);
updateChart(state.cpuHistory, state.labels); updateChart(state.cpuHistory, state.labels, coreCount);
} }
}); });
app.onerror = console.error; 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) { function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.safeAreaInsets) { if (ctx.safeAreaInsets) {
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
@ -392,6 +456,3 @@ app.connect().then(() => {
handleHostContextChanged(ctx); handleHostContextChanged(ctx);
} }
}); });
// Auto-start polling after a short delay
setTimeout(startPolling, 500);