Add system-monitor-server example
Demo MCP App showcasing real-time OS metrics visualization with: - Per-core CPU usage via stacked area chart (Chart.js) - Memory usage bar gauge with color-coded thresholds - Auto-polling at 2-second intervals - Light/dark theme support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f1c995fb99
commit
fa2d413f1d
10 changed files with 1075 additions and 0 deletions
49
examples/system-monitor-server/README.md
Normal file
49
examples/system-monitor-server/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Example: System Monitor App
|
||||
|
||||
A demo MCP App that displays real-time OS metrics with a stacked area chart for per-core CPU usage and a bar gauge for memory.
|
||||
|
||||
## Features
|
||||
|
||||
- **Per-Core CPU Monitoring**: Stacked area chart showing individual CPU core utilization over a 1-minute sliding window
|
||||
- **Memory Usage**: Horizontal bar gauge with color-coded thresholds (green/yellow/red)
|
||||
- **System Info**: Hostname, platform, and uptime display
|
||||
- **Auto-Polling**: Automatically starts monitoring on load with 2-second refresh interval
|
||||
- **Theme Support**: Adapts to light/dark mode preferences
|
||||
|
||||
## Running
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Build and start the server:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The server will listen on `http://localhost:3001/mcp`.
|
||||
|
||||
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Server (`server.ts`)
|
||||
|
||||
Exposes a single `get-system-stats` tool that returns:
|
||||
|
||||
- Raw per-core CPU timing data (idle/total counters)
|
||||
- Memory usage (used/total/percentage)
|
||||
- System info (hostname, platform, uptime)
|
||||
|
||||
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
|
||||
|
||||
### App (`src/mcp-app.ts`)
|
||||
|
||||
- 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
|
||||
60
examples/system-monitor-server/mcp-app.html
Normal file
60
examples/system-monitor-server/mcp-app.html
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>System Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="main">
|
||||
<header class="header">
|
||||
<h1 class="title">System Monitor</h1>
|
||||
<div class="header-controls">
|
||||
<button id="poll-toggle-btn" class="btn">Start</button>
|
||||
<span class="status">
|
||||
<span id="status-indicator" class="status-indicator"></span>
|
||||
<span id="status-text" class="status-text">Ready</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="chart-section">
|
||||
<h2 class="section-title">CPU Usage</h2>
|
||||
<div class="chart-container">
|
||||
<canvas id="cpu-chart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="memory-section">
|
||||
<h2 class="section-title">Memory</h2>
|
||||
<div class="memory-bar-container">
|
||||
<div class="memory-bar">
|
||||
<div id="memory-bar-fill" class="memory-bar-fill"></div>
|
||||
</div>
|
||||
<span id="memory-percent" class="memory-percent">--%</span>
|
||||
</div>
|
||||
<div id="memory-detail" class="memory-detail">-- / --</div>
|
||||
</section>
|
||||
|
||||
<section class="info-section">
|
||||
<h2 class="section-title">System Info</h2>
|
||||
<dl class="info-list">
|
||||
<div class="info-item">
|
||||
<dt>Hostname</dt>
|
||||
<dd id="info-hostname">--</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Platform</dt>
|
||||
<dd id="info-platform">--</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Uptime</dt>
|
||||
<dd id="info-uptime">--</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./src/mcp-app.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
examples/system-monitor-server/package.json
Normal file
31
examples/system-monitor-server/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "system-monitor-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "INPUT=mcp-app.html vite build",
|
||||
"watch": "INPUT=mcp-app.html vite build --watch",
|
||||
"serve": "bun server.ts",
|
||||
"start": "NODE_ENV=development npm run build && npm run serve",
|
||||
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "../..",
|
||||
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"systeminformation": "^5.27.11",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
}
|
||||
}
|
||||
218
examples/system-monitor-server/server.ts
Normal file
218
examples/system-monitor-server/server.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import cors from "cors";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import si from "systeminformation";
|
||||
import { z } from "zod";
|
||||
import { RESOURCE_URI_META_KEY } from "../../dist/src/app";
|
||||
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
|
||||
|
||||
// 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(),
|
||||
});
|
||||
|
||||
const MemoryStatsSchema = z.object({
|
||||
usedBytes: z.number(),
|
||||
totalBytes: z.number(),
|
||||
usedPercent: z.number(),
|
||||
freeBytes: z.number(),
|
||||
usedFormatted: z.string(),
|
||||
totalFormatted: z.string(),
|
||||
});
|
||||
|
||||
const SystemInfoSchema = z.object({
|
||||
hostname: z.string(),
|
||||
platform: z.string(),
|
||||
arch: z.string(),
|
||||
uptime: z.number(),
|
||||
uptimeFormatted: z.string(),
|
||||
});
|
||||
|
||||
const SystemStatsSchema = z.object({
|
||||
cpu: CpuStatsSchema,
|
||||
memory: MemoryStatsSchema,
|
||||
system: SystemInfoSchema,
|
||||
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>;
|
||||
const DIST_DIR = path.join(import.meta.dirname, "dist");
|
||||
|
||||
// Returns raw CPU timing data per core (client calculates usage from deltas)
|
||||
function getCpuSnapshots(): CpuCore[] {
|
||||
return os.cpus().map((cpu) => {
|
||||
const times = cpu.times;
|
||||
const idle = times.idle;
|
||||
const total = times.user + times.nice + times.sys + times.idle + times.irq;
|
||||
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> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
server.registerTool(
|
||||
"get-system-stats",
|
||||
{
|
||||
title: "Get System Stats",
|
||||
description:
|
||||
"Returns current system statistics including per-core CPU usage, memory, and system info.",
|
||||
inputSchema: {},
|
||||
outputSchema: SystemStatsSchema.shape,
|
||||
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
|
||||
},
|
||||
async (): Promise<CallToolResult> => {
|
||||
const cpuSnapshots = getCpuSnapshots();
|
||||
const cpuInfo = os.cpus()[0];
|
||||
const memory = await getMemoryStats();
|
||||
const uptimeSeconds = os.uptime();
|
||||
|
||||
const stats: SystemStats = {
|
||||
cpu: {
|
||||
cores: cpuSnapshots,
|
||||
model: cpuInfo?.model ?? "Unknown",
|
||||
count: os.cpus().length,
|
||||
},
|
||||
memory,
|
||||
system: {
|
||||
hostname: os.hostname(),
|
||||
platform: `${os.platform()} ${os.arch()}`,
|
||||
arch: os.arch(),
|
||||
uptime: uptimeSeconds,
|
||||
uptimeFormatted: formatUptime(uptimeSeconds),
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(stats, null, 2) }],
|
||||
structuredContent: stats,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
resourceUri,
|
||||
resourceUri,
|
||||
{ description: "System Monitor UI" },
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
const html = await fs.readFile(
|
||||
path.join(DIST_DIR, "mcp-app.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return {
|
||||
contents: [{ uri: resourceUri, mimeType: "text/html+mcp", text: html }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error("Error handling MCP request:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = app.listen(PORT, () => {
|
||||
console.log(
|
||||
`System Monitor Server listening on http://localhost:${PORT}/mcp`,
|
||||
);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
console.log("\nShutting down...");
|
||||
httpServer.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
12
examples/system-monitor-server/src/global.css
Normal file
12
examples/system-monitor-server/src/global.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 1em;
|
||||
}
|
||||
233
examples/system-monitor-server/src/mcp-app.css
Normal file
233
examples/system-monitor-server/src/mcp-app.css
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-text: #1f2937;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #1d4ed8;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
--color-bar-bg: #e5e7eb;
|
||||
--color-card-bg: #f9fafb;
|
||||
--color-border: #e5e7eb;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #111827;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-muted: #9ca3af;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #60a5fa;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-danger: #f87171;
|
||||
--color-bar-bg: #374151;
|
||||
--color-card-bg: #1f2937;
|
||||
--color-border: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 16px;
|
||||
min-width: 60px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn.active:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
min-width: 8ch;
|
||||
font-family: ui-monospace, monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-indicator.polling {
|
||||
background: var(--color-success);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Section titles */
|
||||
.section-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Chart section */
|
||||
.chart-section {
|
||||
background: var(--color-card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
/* Memory section */
|
||||
.memory-section {
|
||||
background: var(--color-card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.memory-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memory-bar {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: var(--color-bar-bg);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--color-success);
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.memory-bar-fill.warning {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.memory-bar-fill.danger {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.memory-percent {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.memory-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Info section */
|
||||
.info-section {
|
||||
background: var(--color-card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-item dt {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.info-item dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
360
examples/system-monitor-server/src/mcp-app.ts
Normal file
360
examples/system-monitor-server/src/mcp-app.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
/**
|
||||
* @file System Monitor App - displays real-time OS metrics with Chart.js
|
||||
*/
|
||||
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import "./global.css";
|
||||
import "./mcp-app.css";
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables);
|
||||
|
||||
const log = {
|
||||
info: console.log.bind(console, "[APP]"),
|
||||
error: console.error.bind(console, "[APP]"),
|
||||
};
|
||||
|
||||
// Types for system stats response
|
||||
interface SystemStats {
|
||||
cpu: {
|
||||
cores: Array<{ idle: number; total: number }>;
|
||||
model: string;
|
||||
count: number;
|
||||
};
|
||||
memory: {
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
usedPercent: number;
|
||||
freeBytes: number;
|
||||
usedFormatted: string;
|
||||
totalFormatted: string;
|
||||
};
|
||||
system: {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
uptime: number;
|
||||
uptimeFormatted: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// DOM element references
|
||||
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")!;
|
||||
|
||||
// Polling state
|
||||
const HISTORY_LENGTH = 30;
|
||||
const POLL_INTERVAL = 2000;
|
||||
|
||||
interface PollingState {
|
||||
isPolling: boolean;
|
||||
intervalId: number | null;
|
||||
cpuHistory: number[][]; // [timestamp][coreIndex] = usage%
|
||||
labels: string[];
|
||||
coreCount: number;
|
||||
chart: Chart | null;
|
||||
previousCpuSnapshots: Array<{ idle: number; total: number }> | null;
|
||||
}
|
||||
|
||||
const state: PollingState = {
|
||||
isPolling: false,
|
||||
intervalId: null,
|
||||
cpuHistory: [],
|
||||
labels: [],
|
||||
coreCount: 0,
|
||||
chart: null,
|
||||
previousCpuSnapshots: null,
|
||||
};
|
||||
|
||||
// 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
|
||||
];
|
||||
|
||||
// Calculate CPU usage percentages from raw timing data
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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[]): 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++) {
|
||||
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 = state.coreCount * 15; // At least 15% per core visible
|
||||
const absoluteMax = state.coreCount * 100;
|
||||
|
||||
const dynamicMax = Math.min(
|
||||
Math.max(currentMax * headroom, minVisible),
|
||||
absoluteMax,
|
||||
);
|
||||
|
||||
state.chart.options.scales!.y!.max = dynamicMax;
|
||||
|
||||
state.chart.update("none");
|
||||
}
|
||||
|
||||
function updateMemoryBar(memory: SystemStats["memory"]): 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 = `${memory.usedFormatted} / ${memory.totalFormatted}`;
|
||||
}
|
||||
|
||||
function updateSystemInfo(system: SystemStats["system"]): void {
|
||||
infoHostname.textContent = system.hostname;
|
||||
infoPlatform.textContent = system.platform;
|
||||
infoUptime.textContent = system.uptimeFormatted;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
// Create app instance
|
||||
const app = new App({ name: "System Monitor", version: "1.0.0" });
|
||||
|
||||
async function fetchStats(): Promise<void> {
|
||||
try {
|
||||
const result = await app.callServerTool({
|
||||
name: "get-system-stats",
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
updateChart(state.cpuHistory, state.labels);
|
||||
updateMemoryBar(stats.memory);
|
||||
updateSystemInfo(stats.system);
|
||||
|
||||
const time = new Date().toLocaleTimeString("en-US", { hour12: false });
|
||||
updateStatus(time, true);
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch stats:", error);
|
||||
updateStatus("Error", false, true);
|
||||
}
|
||||
}
|
||||
|
||||
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 listeners
|
||||
pollToggleBtn.addEventListener("click", togglePolling);
|
||||
|
||||
// Handle theme changes
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", () => {
|
||||
if (state.chart) {
|
||||
state.chart.destroy();
|
||||
state.chart = initChart(state.coreCount);
|
||||
updateChart(state.cpuHistory, state.labels);
|
||||
}
|
||||
});
|
||||
|
||||
// Register handlers and connect
|
||||
app.onerror = log.error;
|
||||
|
||||
app.connect(new PostMessageTransport(window.parent));
|
||||
|
||||
// Auto-start polling after a short delay
|
||||
setTimeout(startPolling, 500);
|
||||
19
examples/system-monitor-server/tsconfig.json
Normal file
19
examples/system-monitor-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "server.ts"]
|
||||
}
|
||||
24
examples/system-monitor-server/vite.config.ts
Normal file
24
examples/system-monitor-server/vite.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
|
||||
const INPUT = process.env.INPUT;
|
||||
if (!INPUT) {
|
||||
throw new Error("INPUT environment variable is not set");
|
||||
}
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [viteSingleFile()],
|
||||
build: {
|
||||
sourcemap: isDevelopment ? "inline" : undefined,
|
||||
cssMinify: !isDevelopment,
|
||||
minify: !isDevelopment,
|
||||
|
||||
rollupOptions: {
|
||||
input: INPUT,
|
||||
},
|
||||
outDir: "dist",
|
||||
emptyOutDir: false,
|
||||
},
|
||||
});
|
||||
69
package-lock.json
generated
69
package-lock.json
generated
|
|
@ -160,6 +160,27 @@
|
|||
"vite-plugin-singlefile": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"examples/system-monitor-server": {
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "../..",
|
||||
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"systeminformation": "^5.27.11",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"dev": true,
|
||||
|
|
@ -471,6 +492,12 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/ext-apps": {
|
||||
"resolved": "",
|
||||
"link": true
|
||||
|
|
@ -1344,6 +1371,18 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.1",
|
||||
"dev": true,
|
||||
|
|
@ -2944,6 +2983,36 @@
|
|||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/system-monitor-server": {
|
||||
"resolved": "examples/system-monitor-server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/systeminformation": {
|
||||
"version": "5.27.11",
|
||||
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.11.tgz",
|
||||
"integrity": "sha512-K3Lto/2m3K2twmKHdgx5B+0in9qhXK4YnoT9rIlgwN/4v7OV5c8IjbeAUkuky/6VzCQC7iKCAqi8rZathCdjHg==",
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32",
|
||||
"freebsd",
|
||||
"openbsd",
|
||||
"netbsd",
|
||||
"sunos",
|
||||
"android"
|
||||
],
|
||||
"bin": {
|
||||
"systeminformation": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "Buy me a coffee",
|
||||
"url": "https://www.buymeacoffee.com/systeminfo"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"dev": true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue