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:
parent
6028cf255f
commit
9acebd7f9f
3 changed files with 206 additions and 149 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue