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>
This commit is contained in:
Jonathan Hefner 2026-01-28 10:37:00 -06:00
commit 9acebd7f9f
No known key found for this signature in database
GPG key ID: 0DA40AFD21043691
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`)
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)
- Memory usage (used/total/percentage)
- System info (hostname, platform, uptime)
1. **`get-system-info`** (Model-visible) — Returns static system configuration:
- Hostname, platform, architecture
- 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`)
- 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
- Polls the server tool every 2 seconds
- Computes CPU usage percentages client-side from timing deltas
- 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 si from "systeminformation";
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({
cores: z.array(CpuCoreSchema),
model: z.string(),
count: z.number(),
});
// 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")
: import.meta.dirname;
const MemoryStatsSchema = z.object({
usedBytes: z.number(),
totalBytes: z.number(),
usedPercent: z.number(),
freeBytes: z.number(),
usedFormatted: z.string(),
totalFormatted: z.string(),
});
// =============================================================================
// Types and schemas
// =============================================================================
const SystemInfoSchema = z.object({
hostname: z.string(),
platform: z.string(),
arch: z.string(),
uptime: z.number(),
uptimeFormatted: z.string(),
cpu: z.object({
model: z.string(),
count: z.number(),
}),
memory: z.object({
totalBytes: z.number(),
}),
});
const SystemStatsSchema = z.object({
cpu: CpuStatsSchema,
memory: MemoryStatsSchema,
system: SystemInfoSchema,
type SystemInfo = z.infer<typeof SystemInfoSchema>;
const CpuCoreSchema = z.object({
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(),
});
// Types derived from schemas
type CpuCore = z.infer<typeof CpuCoreSchema>;
type MemoryStats = z.infer<typeof MemoryStatsSchema>;
type SystemStats = z.infer<typeof SystemStatsSchema>;
// 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")
: import.meta.dirname;
type PollStats = z.infer<typeof PollStatsSchema>;
// =============================================================================
// Static system info (called once by Model-facing tool)
// =============================================================================
function getSystemInfo(): SystemInfo {
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)
function getCpuSnapshots(): CpuCore[] {
@ -67,111 +95,73 @@ function getCpuSnapshots(): CpuCore[] {
return { idle, total };
});
}
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> {
async function getPollStats(): Promise<PollStats> {
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();
return {
cpu: {
cores: cpuSnapshots,
model: cpuInfo?.model ?? "Unknown",
count: os.cpus().length,
cores: getCpuSnapshots(),
},
memory,
system: {
hostname: os.hostname(),
platform: `${os.platform()} ${os.arch()}`,
arch: os.arch(),
uptime: uptimeSeconds,
uptimeFormatted: formatUptime(uptimeSeconds),
memory: {
usedBytes: mem.active,
usedPercent: Math.round((mem.active / mem.total) * 100),
freeBytes: mem.available,
},
uptime: {
seconds: uptimeSeconds,
},
timestamp: new Date().toISOString(),
};
}
// =============================================================================
// MCP server
// =============================================================================
export function createServer(): McpServer {
const server = new McpServer({
name: "System Monitor Server",
version: "1.0.0",
});
// Register the get-system-stats tool and its associated UI resource
const resourceUri = "ui://system-monitor/mcp-app.html";
// Model-facing tool: returns static system configuration
registerAppTool(
server,
"get-system-stats",
"get-system-info",
{
title: "Get System Stats",
title: "Get System Info",
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: {},
outputSchema: SystemStatsSchema.shape,
outputSchema: SystemInfoSchema.shape,
_meta: { ui: { resourceUri } },
},
async (): Promise<CallToolResult> => {
const stats = await getStats();
(): CallToolResult => {
const info = getSystemInfo();
return {
content: [{ type: "text", text: JSON.stringify(stats) }],
structuredContent: stats,
content: [{ type: "text", text: JSON.stringify(info) }],
structuredContent: info,
};
},
);
// App-only tool for polling - used by the UI for periodic refresh
// App-only tool: returns dynamic metrics for polling
registerAppTool(
server,
"refresh-stats",
"poll-system-stats",
{
title: "Refresh Stats",
description: "Refresh system statistics (app-only, for polling)",
title: "Poll System Stats",
description:
"Returns dynamic system metrics for polling: per-core CPU timing, memory usage, and uptime. App-only.",
inputSchema: {},
outputSchema: SystemStatsSchema.shape,
outputSchema: PollStatsSchema.shape,
_meta: { ui: { visibility: ["app"] } },
},
async (): Promise<CallToolResult> => {
const stats = await getStats();
const stats = await getPollStats();
return {
content: [{ type: "text", text: JSON.stringify(stats) }],
structuredContent: stats,

View file

@ -13,36 +13,44 @@ Chart.register(...registerables);
// Types
// =============================================================================
interface SystemStats {
// Static system info (received once via ontoolresult from LLM-facing tool)
interface SystemInfo {
hostname: string;
platform: string;
arch: string;
cpu: {
cores: Array<{ idle: number; total: number }>;
model: string;
count: number;
};
memory: {
usedBytes: 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;
freeBytes: number;
usedFormatted: string;
totalFormatted: string;
};
system: {
hostname: string;
platform: string;
arch: string;
uptime: number;
uptimeFormatted: string;
uptime: {
seconds: number;
};
timestamp: string;
}
interface PollingState {
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[];
coreCount: number;
chart: Chart | null;
previousCpuSnapshots: Array<{ idle: number; total: number }> | null;
}
@ -92,16 +100,42 @@ const CORE_COLORS = [
"rgba(244, 114, 182, 0.7)", // pink-light
];
const state: PollingState = {
const state: AppState = {
systemInfo: null,
isPolling: false,
intervalId: null,
cpuHistory: [],
labels: [],
coreCount: 0,
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
// =============================================================================
@ -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;
state.chart.data.labels = labels;
// 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(
(snapshot) => snapshot[coreIdx] ?? 0,
);
@ -200,8 +238,8 @@ function updateChart(cpuHistory: number[][], labels: string[]): void {
// 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 minVisible = coreCount * 15; // At least 15% per core visible
const absoluteMax = coreCount * 100;
const dynamicMax = Math.min(
Math.max(currentMax * headroom, minVisible),
@ -217,7 +255,10 @@ function updateChart(cpuHistory: number[][], labels: string[]): void {
// UI Updates
// =============================================================================
function updateMemoryBar(memory: SystemStats["memory"]): void {
function updateMemoryBar(
memory: PollStats["memory"],
totalBytes: number,
): void {
const percent = memory.usedPercent;
memoryBarFill.style.width = `${percent}%`;
@ -230,13 +271,18 @@ function updateMemoryBar(memory: SystemStats["memory"]): void {
}
memoryPercent.textContent = `${percent}%`;
memoryDetail.textContent = `${memory.usedFormatted} / ${memory.totalFormatted}`;
memoryDetail.textContent = `${formatBytes(memory.usedBytes)} / ${formatBytes(totalBytes)}`;
}
function updateSystemInfo(system: SystemStats["system"]): void {
infoHostname.textContent = system.hostname;
infoPlatform.textContent = system.platform;
infoUptime.textContent = system.uptimeFormatted;
// 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 {
@ -273,19 +319,18 @@ function calculateCpuUsage(
}
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: "refresh-stats", // Use app-only tool for polling
name: "poll-system-stats", // App-only tool for polling dynamic data
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);
}
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(
@ -302,10 +347,10 @@ async function fetchStats(): Promise<void> {
state.labels.shift();
}
// Update UI
updateChart(state.cpuHistory, state.labels);
updateMemoryBar(stats.memory);
updateSystemInfo(stats.system);
// 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);
@ -366,15 +411,34 @@ pollToggleBtn.addEventListener("click", togglePolling);
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (state.chart) {
if (state.chart && state.systemInfo) {
const coreCount = state.systemInfo.cpu.count;
state.chart.destroy();
state.chart = initChart(state.coreCount);
updateChart(state.cpuHistory, state.labels);
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`;
@ -392,6 +456,3 @@ app.connect().then(() => {
handleHostContextChanged(ctx);
}
});
// Auto-start polling after a short delay
setTimeout(startPolling, 500);