diff --git a/examples/system-monitor-server/README.md b/examples/system-monitor-server/README.md index 572ea1b..6b79794 100644 --- a/examples/system-monitor-server/README.md +++ b/examples/system-monitor-server/README.md @@ -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 diff --git a/examples/system-monitor-server/server.ts b/examples/system-monitor-server/server.ts index 61bfa91..8fac5f2 100644 --- a/examples/system-monitor-server/server.ts +++ b/examples/system-monitor-server/server.ts @@ -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; + +const CpuCoreSchema = z.object({ + idle: z.number(), + total: z.number(), +}); + +type CpuCore = z.infer; + +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; -type MemoryStats = z.infer; -type SystemStats = z.infer; -// 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; + +// ============================================================================= +// 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 { +async function getPollStats(): Promise { 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 { - 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 => { - 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 => { - const stats = await getStats(); + const stats = await getPollStats(); return { content: [{ type: "text", text: JSON.stringify(stats) }], structuredContent: stats, diff --git a/examples/system-monitor-server/src/mcp-app.ts b/examples/system-monitor-server/src/mcp-app.ts index a39ac15..28baf81 100644 --- a/examples/system-monitor-server/src/mcp-app.ts +++ b/examples/system-monitor-server/src/mcp-app.ts @@ -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 { + // 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 { 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);