@megamcp/sentinel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/dist/cli.js +41 -0
- package/dist/config.js +26 -0
- package/dist/format.js +44 -0
- package/dist/inspect.js +88 -0
- package/dist/monitor.js +185 -0
- package/dist/orchestrate.js +33 -0
- package/dist/report.js +114 -0
- package/dist/sample-target/echo-server.js +53 -0
- package/dist/server.js +81 -0
- package/dist/state.js +26 -0
- package/dist/types.js +2 -0
- package/docs-run-production.md +27 -0
- package/package.json +43 -0
- package/run-check.ps1 +13 -0
- package/sentinel.config.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# MegaMCP Sentinel
|
|
2
|
+
|
|
3
|
+
**Monitoring & reliability for MCP servers.** Sentinel speaks the Model Context Protocol — it runs the real lifecycle (`initialize` handshake → `tools/list` → latency → schema-drift diff) against a target MCP server, keeps history, and flags trouble. A generic HTTP ping can't tell you a server is *up but broken*; Sentinel can.
|
|
4
|
+
|
|
5
|
+
It ships two ways:
|
|
6
|
+
- a **CLI** (`check` / `watch`) for cron and CI, and
|
|
7
|
+
- an **MCP server** (`list_targets`, `check_target`, `health_summary`) — i.e. an MCP server that monitors MCP servers.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install
|
|
13
|
+
npm run build
|
|
14
|
+
|
|
15
|
+
# one-shot check of all targets (exits non-zero if any are DOWN)
|
|
16
|
+
npm run check
|
|
17
|
+
|
|
18
|
+
# continuous watch
|
|
19
|
+
npm run watch # every 60s
|
|
20
|
+
node dist/cli.js watch 15
|
|
21
|
+
|
|
22
|
+
# generate ../web/status.json + ../web/status.html
|
|
23
|
+
npm run report
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The repo ships with a bundled **echo-sample** MCP server as a live target, so `npm run check` works with zero external setup.
|
|
27
|
+
|
|
28
|
+
## What it checks (per target)
|
|
29
|
+
|
|
30
|
+
| Signal | Meaning |
|
|
31
|
+
|---|---|
|
|
32
|
+
| **reachable / handshake** | Did `initialize` complete? |
|
|
33
|
+
| **tools/list** | Did the server return its tool catalog? |
|
|
34
|
+
| **toolCount** | How many tools — below `minTools` = degraded |
|
|
35
|
+
| **latency** | connect + list timing; over `latencyBudgetMs` = degraded |
|
|
36
|
+
| **schema drift** | Tools added / removed / changed vs. last known-good |
|
|
37
|
+
|
|
38
|
+
Status is `up` · `degraded` · `down`.
|
|
39
|
+
|
|
40
|
+
## Configure targets
|
|
41
|
+
|
|
42
|
+
Edit `sentinel.config.json`. Two transports:
|
|
43
|
+
|
|
44
|
+
```jsonc
|
|
45
|
+
{ "type": "stdio", "command": "npx", "args": ["-y", "@scope/server"] }
|
|
46
|
+
{ "type": "http", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ..." } }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
See `_examples` in the config file for full shapes. Point `SENTINEL_CONFIG=/path/to.json` to use a different file.
|
|
50
|
+
|
|
51
|
+
## Architecture
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
config.ts ─▶ orchestrate.ts ─▶ monitor.ts ─▶ (MCP client → target)
|
|
55
|
+
│ └─ schema fingerprint + drift
|
|
56
|
+
▼
|
|
57
|
+
state.ts (history + last-known schema, JSON in ./state)
|
|
58
|
+
│
|
|
59
|
+
┌─────────┴─────────┐
|
|
60
|
+
cli.ts server.ts (Sentinel as an MCP server)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`monitor.ts` is the engine and is disk-pure; `orchestrate.ts` wires config → check → persist and is shared by both entry points.
|
|
64
|
+
|
|
65
|
+
## Run Sentinel as an MCP server
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
node dist/server.js
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Add to any MCP client config as a stdio server (`command: node`, `args: [".../dist/server.js"]`), then call `check_target` / `health_summary` from your agent.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
Part of **[MegaMCP](../CLAUDE.md)** — the reliability layer for the MCP ecosystem.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { checkTargets } from "./orchestrate.js";
|
|
3
|
+
import { renderResult, renderSummary } from "./format.js";
|
|
4
|
+
const USAGE = "Usage: megamcp-sentinel <check|watch> [targetId] [--json]";
|
|
5
|
+
async function main() {
|
|
6
|
+
const argv = process.argv.slice(2);
|
|
7
|
+
const cmd = argv[0] ?? "check";
|
|
8
|
+
const asJson = argv.includes("--json");
|
|
9
|
+
const positional = argv.slice(1).filter((a) => !a.startsWith("--"));
|
|
10
|
+
if (cmd === "check") {
|
|
11
|
+
const results = await checkTargets(positional[0]);
|
|
12
|
+
if (asJson) {
|
|
13
|
+
console.log(JSON.stringify(results, null, 2));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
for (const r of results)
|
|
17
|
+
console.log(renderResult(r) + "\n");
|
|
18
|
+
console.log(renderSummary(results));
|
|
19
|
+
}
|
|
20
|
+
process.exit(results.some((r) => r.status === "down") ? 1 : 0);
|
|
21
|
+
}
|
|
22
|
+
else if (cmd === "watch") {
|
|
23
|
+
const intervalSec = Number(positional[0] ?? 60);
|
|
24
|
+
console.log(`Sentinel watching every ${intervalSec}s. Ctrl+C to stop.\n`);
|
|
25
|
+
for (;;) {
|
|
26
|
+
const results = await checkTargets();
|
|
27
|
+
for (const r of results)
|
|
28
|
+
console.log(renderResult(r) + "\n");
|
|
29
|
+
console.log(renderSummary(results) + "\n" + "─".repeat(60) + "\n");
|
|
30
|
+
await new Promise((r) => setTimeout(r, intervalSec * 1000));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.error(USAGE);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
main().catch((e) => {
|
|
39
|
+
console.error(e instanceof Error ? e.message : e);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Load monitoring targets from a JSON file.
|
|
5
|
+
* Accepts either a bare array of targets or `{ "targets": [...] }`.
|
|
6
|
+
* Keys beginning with "_" (e.g. "_examples") are ignored — handy for inline docs.
|
|
7
|
+
*/
|
|
8
|
+
export function loadConfig(path = "sentinel.config.json") {
|
|
9
|
+
const full = resolve(process.cwd(), path);
|
|
10
|
+
if (!existsSync(full)) {
|
|
11
|
+
throw new Error(`Config not found: ${full}`);
|
|
12
|
+
}
|
|
13
|
+
const raw = JSON.parse(readFileSync(full, "utf8"));
|
|
14
|
+
const targets = Array.isArray(raw)
|
|
15
|
+
? raw
|
|
16
|
+
: (raw.targets ?? []);
|
|
17
|
+
if (!targets.length) {
|
|
18
|
+
throw new Error(`No targets defined in ${full}`);
|
|
19
|
+
}
|
|
20
|
+
for (const t of targets) {
|
|
21
|
+
if (!t.id || !t.transport) {
|
|
22
|
+
throw new Error(`Invalid target (needs id + transport): ${JSON.stringify(t)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { targets };
|
|
26
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const C = {
|
|
2
|
+
reset: "\x1b[0m",
|
|
3
|
+
bold: "\x1b[1m",
|
|
4
|
+
dim: "\x1b[2m",
|
|
5
|
+
green: "\x1b[32m",
|
|
6
|
+
yellow: "\x1b[33m",
|
|
7
|
+
red: "\x1b[31m",
|
|
8
|
+
};
|
|
9
|
+
function badge(status) {
|
|
10
|
+
if (status === "up")
|
|
11
|
+
return `${C.green}● UP${C.reset}`;
|
|
12
|
+
if (status === "degraded")
|
|
13
|
+
return `${C.yellow}● DEGRADED${C.reset}`;
|
|
14
|
+
return `${C.red}● DOWN${C.reset}`;
|
|
15
|
+
}
|
|
16
|
+
export function renderResult(r) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
lines.push(`${badge(r.status)} ${C.bold}${r.label}${C.reset} ${C.dim}(${r.targetId})${C.reset}`);
|
|
19
|
+
if (r.serverInfo?.name) {
|
|
20
|
+
lines.push(` server: ${r.serverInfo.name}${r.serverInfo.version ? ` v${r.serverInfo.version}` : ""}`);
|
|
21
|
+
}
|
|
22
|
+
lines.push(` tools: ${r.toolCount} latency: ${r.latencyMs.total}ms ` +
|
|
23
|
+
`${C.dim}(connect ${r.latencyMs.connect ?? "—"}ms · list ${r.latencyMs.toolsList ?? "—"}ms)${C.reset}`);
|
|
24
|
+
if (r.schema.drift) {
|
|
25
|
+
lines.push(` ${C.yellow}schema drift:${C.reset} +${r.schema.added.length} -${r.schema.removed.length} ~${r.schema.changed.length}`);
|
|
26
|
+
if (r.schema.added.length)
|
|
27
|
+
lines.push(` ${C.green}+ ${r.schema.added.join(", ")}${C.reset}`);
|
|
28
|
+
if (r.schema.removed.length)
|
|
29
|
+
lines.push(` ${C.red}- ${r.schema.removed.join(", ")}${C.reset}`);
|
|
30
|
+
if (r.schema.changed.length)
|
|
31
|
+
lines.push(` ${C.yellow}~ ${r.schema.changed.join(", ")}${C.reset}`);
|
|
32
|
+
}
|
|
33
|
+
if (r.error)
|
|
34
|
+
lines.push(` ${C.red}error: ${r.error}${C.reset}`);
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
37
|
+
export function renderSummary(results) {
|
|
38
|
+
const up = results.filter((r) => r.status === "up").length;
|
|
39
|
+
const deg = results.filter((r) => r.status === "degraded").length;
|
|
40
|
+
const down = results.filter((r) => r.status === "down").length;
|
|
41
|
+
return (`${C.bold}Summary:${C.reset} ` +
|
|
42
|
+
`${C.green}${up} up${C.reset}, ${C.yellow}${deg} degraded${C.reset}, ${C.red}${down} down${C.reset} ` +
|
|
43
|
+
`${C.dim}(${results.length} targets)${C.reset}`);
|
|
44
|
+
}
|
package/dist/inspect.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MegaMCP Inspector — devtools for MCP. Connect to any server, browse its
|
|
3
|
+
// tools + schemas, and fire calls. Shares Sentinel's transport layer.
|
|
4
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
|
+
import { loadConfig } from "./config.js";
|
|
8
|
+
const C = { reset: "\x1b[0m", b: "\x1b[1m", dim: "\x1b[2m", o: "\x1b[38;5;208m" };
|
|
9
|
+
function makeTransport(t) {
|
|
10
|
+
if (t.type === "stdio") {
|
|
11
|
+
const env = {};
|
|
12
|
+
for (const [k, v] of Object.entries(process.env))
|
|
13
|
+
if (typeof v === "string")
|
|
14
|
+
env[k] = v;
|
|
15
|
+
return new StdioClientTransport({
|
|
16
|
+
command: t.command,
|
|
17
|
+
args: t.args ?? [],
|
|
18
|
+
env: { ...env, ...(t.env ?? {}) },
|
|
19
|
+
stderr: "ignore",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return new StreamableHTTPClientTransport(new URL(t.url), {
|
|
23
|
+
requestInit: t.headers ? { headers: t.headers } : undefined,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function resolveTarget(idOrUrl) {
|
|
27
|
+
try {
|
|
28
|
+
const { targets } = loadConfig(process.env.SENTINEL_CONFIG || "sentinel.config.json");
|
|
29
|
+
const found = targets.find((t) => t.id === idOrUrl);
|
|
30
|
+
if (found)
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* no config available — fall through to ad-hoc */
|
|
35
|
+
}
|
|
36
|
+
if (/^https?:\/\//.test(idOrUrl)) {
|
|
37
|
+
return { id: idOrUrl, label: idOrUrl, transport: { type: "http", url: idOrUrl } };
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`Unknown target "${idOrUrl}". Pass a config target id or an http(s) URL.`);
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
const argv = process.argv.slice(2);
|
|
43
|
+
const targetArg = argv[0];
|
|
44
|
+
if (!targetArg) {
|
|
45
|
+
console.error("Usage: megamcp-inspect <targetId|url> [--call <tool> --args '<json>']");
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
const callIdx = argv.indexOf("--call");
|
|
49
|
+
const argsIdx = argv.indexOf("--args");
|
|
50
|
+
const toolName = callIdx >= 0 ? argv[callIdx + 1] : undefined;
|
|
51
|
+
const toolArgs = argsIdx >= 0 ? JSON.parse(argv[argsIdx + 1]) : {};
|
|
52
|
+
const target = resolveTarget(targetArg);
|
|
53
|
+
const client = new Client({ name: "megamcp-inspect", version: "0.1.0" }, { capabilities: {} });
|
|
54
|
+
await client.connect(makeTransport(target.transport));
|
|
55
|
+
const v = client
|
|
56
|
+
.getServerVersion?.();
|
|
57
|
+
console.log(`${C.b}${target.label}${C.reset} ${C.dim}— ${v?.name ?? "?"} v${v?.version ?? "?"}${C.reset}\n`);
|
|
58
|
+
if (toolName) {
|
|
59
|
+
const res = await client.callTool({ name: toolName, arguments: toolArgs });
|
|
60
|
+
console.log(`${C.o}call ${toolName}(${JSON.stringify(toolArgs)})${C.reset}`);
|
|
61
|
+
console.log(JSON.stringify(res, null, 2));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const { tools } = await client.listTools();
|
|
65
|
+
console.log(`${C.o}${tools.length} tools${C.reset}`);
|
|
66
|
+
for (const t of tools) {
|
|
67
|
+
console.log(`\n ${C.b}${t.name}${C.reset}${t.description ? ` ${C.dim}— ${t.description}${C.reset}` : ""}`);
|
|
68
|
+
const schema = (t.inputSchema ?? {});
|
|
69
|
+
const props = schema.properties ?? {};
|
|
70
|
+
const required = schema.required ?? [];
|
|
71
|
+
const keys = Object.keys(props);
|
|
72
|
+
if (!keys.length) {
|
|
73
|
+
console.log(` ${C.dim}(no parameters)${C.reset}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const k of keys) {
|
|
77
|
+
const p = props[k];
|
|
78
|
+
const mark = required.includes(k) ? `${C.o}*${C.reset}` : " ";
|
|
79
|
+
console.log(` ${mark} ${k}: ${C.dim}${p.type ?? "any"}${p.description ? " — " + p.description : ""}${C.reset}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await client.close();
|
|
84
|
+
}
|
|
85
|
+
main().catch((e) => {
|
|
86
|
+
console.error(e instanceof Error ? e.message : e);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
package/dist/monitor.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
7
|
+
/**
|
|
8
|
+
* Run one MCP-native health check against a target:
|
|
9
|
+
* reachability -> initialize handshake -> tools/list -> latency -> schema drift.
|
|
10
|
+
* Pure with respect to disk — persistence is the caller's job.
|
|
11
|
+
*/
|
|
12
|
+
export async function runCheck(target, prior) {
|
|
13
|
+
const timestamp = new Date().toISOString();
|
|
14
|
+
const notes = [];
|
|
15
|
+
const latency = { connect: null, toolsList: null, total: 0 };
|
|
16
|
+
const t0 = performance.now();
|
|
17
|
+
const timeoutMs = target.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
18
|
+
let client = null;
|
|
19
|
+
let reachable = false;
|
|
20
|
+
let handshakeOk = false;
|
|
21
|
+
let toolsListOk = false;
|
|
22
|
+
let toolCount = 0;
|
|
23
|
+
let serverInfo;
|
|
24
|
+
let newTools = [];
|
|
25
|
+
let error;
|
|
26
|
+
try {
|
|
27
|
+
const transport = makeTransport(target);
|
|
28
|
+
client = new Client({ name: "megamcp-sentinel", version: "0.1.0" }, { capabilities: {} });
|
|
29
|
+
const cStart = performance.now();
|
|
30
|
+
await withTimeout(client.connect(transport), timeoutMs, "connect/initialize");
|
|
31
|
+
latency.connect = Math.round(performance.now() - cStart);
|
|
32
|
+
reachable = true;
|
|
33
|
+
handshakeOk = true;
|
|
34
|
+
// Server identity (best-effort; method presence varies by SDK version).
|
|
35
|
+
try {
|
|
36
|
+
const v = client
|
|
37
|
+
.getServerVersion?.();
|
|
38
|
+
if (v)
|
|
39
|
+
serverInfo = { name: v.name, version: v.version };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* non-fatal */
|
|
43
|
+
}
|
|
44
|
+
const lStart = performance.now();
|
|
45
|
+
const listed = await withTimeout(client.listTools(), timeoutMs, "tools/list");
|
|
46
|
+
latency.toolsList = Math.round(performance.now() - lStart);
|
|
47
|
+
toolsListOk = true;
|
|
48
|
+
const toolList = listed.tools ?? [];
|
|
49
|
+
toolCount = toolList.length;
|
|
50
|
+
newTools = toolList.map((t) => ({ name: t.name, hash: hashTool(t.name, t.inputSchema) }));
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
error = e instanceof Error ? e.message : String(e);
|
|
54
|
+
notes.push(`Check failed: ${error}`);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
if (client) {
|
|
58
|
+
try {
|
|
59
|
+
await client.close();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* ignore close errors */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
latency.total = Math.round(performance.now() - t0);
|
|
67
|
+
// Drift is only meaningful when we actually retrieved the current tool list.
|
|
68
|
+
const currentTools = toolsListOk ? newTools : prior.tools;
|
|
69
|
+
const drift = computeDrift(prior.tools, currentTools);
|
|
70
|
+
const fingerprint = toolsListOk ? fingerprintTools(newTools) : prior.fingerprint ?? "";
|
|
71
|
+
if (toolsListOk && prior.fingerprint && drift.drift) {
|
|
72
|
+
notes.push(`Schema drift detected: +${drift.added.length} added, -${drift.removed.length} removed, ~${drift.changed.length} changed`);
|
|
73
|
+
}
|
|
74
|
+
const status = determineStatus({ reachable, handshakeOk, toolsListOk, toolCount, latency, target, drift: drift.drift, notes });
|
|
75
|
+
const result = {
|
|
76
|
+
targetId: target.id,
|
|
77
|
+
label: target.label,
|
|
78
|
+
status,
|
|
79
|
+
timestamp,
|
|
80
|
+
reachable,
|
|
81
|
+
handshakeOk,
|
|
82
|
+
toolsListOk,
|
|
83
|
+
toolCount,
|
|
84
|
+
latencyMs: latency,
|
|
85
|
+
serverInfo,
|
|
86
|
+
schema: {
|
|
87
|
+
fingerprint,
|
|
88
|
+
drift: drift.drift,
|
|
89
|
+
added: drift.added,
|
|
90
|
+
removed: drift.removed,
|
|
91
|
+
changed: drift.changed,
|
|
92
|
+
},
|
|
93
|
+
notes,
|
|
94
|
+
error,
|
|
95
|
+
};
|
|
96
|
+
return { result, tools: currentTools };
|
|
97
|
+
}
|
|
98
|
+
function determineStatus(args) {
|
|
99
|
+
const { reachable, handshakeOk, toolsListOk, toolCount, latency, target, drift, notes } = args;
|
|
100
|
+
if (!reachable || !handshakeOk || !toolsListOk)
|
|
101
|
+
return "down";
|
|
102
|
+
let status = "up";
|
|
103
|
+
if (target.minTools !== undefined && toolCount < target.minTools) {
|
|
104
|
+
status = "degraded";
|
|
105
|
+
notes.push(`Tool count ${toolCount} below expected minimum ${target.minTools}`);
|
|
106
|
+
}
|
|
107
|
+
if (target.latencyBudgetMs !== undefined && latency.total > target.latencyBudgetMs) {
|
|
108
|
+
status = "degraded";
|
|
109
|
+
notes.push(`Latency ${latency.total}ms exceeds budget ${target.latencyBudgetMs}ms`);
|
|
110
|
+
}
|
|
111
|
+
if (drift)
|
|
112
|
+
status = "degraded";
|
|
113
|
+
return status;
|
|
114
|
+
}
|
|
115
|
+
function makeTransport(target) {
|
|
116
|
+
const tr = target.transport;
|
|
117
|
+
if (tr.type === "stdio") {
|
|
118
|
+
return new StdioClientTransport({
|
|
119
|
+
command: tr.command,
|
|
120
|
+
args: tr.args ?? [],
|
|
121
|
+
env: { ...stringEnv(process.env), ...(tr.env ?? {}) },
|
|
122
|
+
stderr: "ignore",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return new StreamableHTTPClientTransport(new URL(tr.url), {
|
|
126
|
+
requestInit: tr.headers ? { headers: tr.headers } : undefined,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function stringEnv(env) {
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const [k, v] of Object.entries(env)) {
|
|
132
|
+
if (typeof v === "string")
|
|
133
|
+
out[k] = v;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
function hashTool(name, inputSchema) {
|
|
138
|
+
return createHash("sha256").update(`${name}\n${stableStringify(inputSchema)}`).digest("hex").slice(0, 16);
|
|
139
|
+
}
|
|
140
|
+
function fingerprintTools(tools) {
|
|
141
|
+
const joined = tools.map((t) => `${t.name}:${t.hash}`).sort().join("|");
|
|
142
|
+
return createHash("sha256").update(joined).digest("hex").slice(0, 16);
|
|
143
|
+
}
|
|
144
|
+
/** Deterministic JSON so semantically-equal schemas hash identically. */
|
|
145
|
+
function stableStringify(v) {
|
|
146
|
+
if (v === null || typeof v !== "object")
|
|
147
|
+
return JSON.stringify(v) ?? "null";
|
|
148
|
+
if (Array.isArray(v))
|
|
149
|
+
return `[${v.map(stableStringify).join(",")}]`;
|
|
150
|
+
const obj = v;
|
|
151
|
+
const keys = Object.keys(obj).sort();
|
|
152
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
153
|
+
}
|
|
154
|
+
function computeDrift(prev, next) {
|
|
155
|
+
const prevMap = new Map(prev.map((t) => [t.name, t.hash]));
|
|
156
|
+
const nextMap = new Map(next.map((t) => [t.name, t.hash]));
|
|
157
|
+
const added = [];
|
|
158
|
+
const removed = [];
|
|
159
|
+
const changed = [];
|
|
160
|
+
for (const [name, hash] of nextMap) {
|
|
161
|
+
if (!prevMap.has(name))
|
|
162
|
+
added.push(name);
|
|
163
|
+
else if (prevMap.get(name) !== hash)
|
|
164
|
+
changed.push(name);
|
|
165
|
+
}
|
|
166
|
+
for (const name of prevMap.keys()) {
|
|
167
|
+
if (!nextMap.has(name))
|
|
168
|
+
removed.push(name);
|
|
169
|
+
}
|
|
170
|
+
const drift = prev.length > 0 && (added.length > 0 || removed.length > 0 || changed.length > 0);
|
|
171
|
+
return { drift, added, removed, changed };
|
|
172
|
+
}
|
|
173
|
+
async function withTimeout(p, ms, label) {
|
|
174
|
+
let timer;
|
|
175
|
+
const timeout = new Promise((_, reject) => {
|
|
176
|
+
timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms during ${label}`)), ms);
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
return await Promise.race([p, timeout]);
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
if (timer)
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { runCheck } from "./monitor.js";
|
|
3
|
+
import { readState, writeState } from "./state.js";
|
|
4
|
+
export function configPath() {
|
|
5
|
+
return process.env.SENTINEL_CONFIG || "sentinel.config.json";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Check one target (by id) or all of them, persisting history + schema baseline.
|
|
9
|
+
* Returns the results in config order.
|
|
10
|
+
*/
|
|
11
|
+
export async function checkTargets(filterId) {
|
|
12
|
+
const { targets } = loadConfig(configPath());
|
|
13
|
+
const selected = filterId ? targets.filter((t) => t.id === filterId) : targets;
|
|
14
|
+
if (filterId && selected.length === 0) {
|
|
15
|
+
throw new Error(`No target matched id: ${filterId}`);
|
|
16
|
+
}
|
|
17
|
+
const results = [];
|
|
18
|
+
for (const target of selected) {
|
|
19
|
+
const prior = readState(target.id);
|
|
20
|
+
const { result, tools } = await runCheck(target, {
|
|
21
|
+
fingerprint: prior?.lastFingerprint ?? null,
|
|
22
|
+
tools: prior?.lastTools ?? [],
|
|
23
|
+
});
|
|
24
|
+
writeState({
|
|
25
|
+
targetId: target.id,
|
|
26
|
+
lastFingerprint: result.schema.fingerprint || (prior?.lastFingerprint ?? null),
|
|
27
|
+
lastTools: tools,
|
|
28
|
+
history: [...(prior?.history ?? []), result],
|
|
29
|
+
});
|
|
30
|
+
results.push(result);
|
|
31
|
+
}
|
|
32
|
+
return results;
|
|
33
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { checkTargets } from "./orchestrate.js";
|
|
5
|
+
const DEFAULT_OUT_DIR = "../web";
|
|
6
|
+
async function main() {
|
|
7
|
+
const outDir = resolve(process.cwd(), process.argv[2] ?? DEFAULT_OUT_DIR);
|
|
8
|
+
const results = await checkTargets();
|
|
9
|
+
const report = buildReport(results);
|
|
10
|
+
mkdirSync(outDir, { recursive: true });
|
|
11
|
+
writeFileSync(resolve(outDir, "status.json"), JSON.stringify(report, null, 2));
|
|
12
|
+
writeFileSync(resolve(outDir, "status.html"), renderHtml(report));
|
|
13
|
+
console.log(`Wrote ${resolve(outDir, "status.json")}`);
|
|
14
|
+
console.log(`Wrote ${resolve(outDir, "status.html")}`);
|
|
15
|
+
}
|
|
16
|
+
function buildReport(targets) {
|
|
17
|
+
const up = targets.filter((r) => r.status === "up").length;
|
|
18
|
+
const degraded = targets.filter((r) => r.status === "degraded").length;
|
|
19
|
+
const down = targets.filter((r) => r.status === "down").length;
|
|
20
|
+
const latencies = targets.map((r) => r.latencyMs.total).filter((n) => Number.isFinite(n));
|
|
21
|
+
const averageLatencyMs = latencies.length
|
|
22
|
+
? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length)
|
|
23
|
+
: null;
|
|
24
|
+
return {
|
|
25
|
+
generatedAt: new Date().toISOString(),
|
|
26
|
+
summary: {
|
|
27
|
+
total: targets.length,
|
|
28
|
+
up,
|
|
29
|
+
degraded,
|
|
30
|
+
down,
|
|
31
|
+
availabilityPct: targets.length ? Math.round(((up + degraded) / targets.length) * 1000) / 10 : 0,
|
|
32
|
+
driftCount: targets.filter((r) => r.schema.drift).length,
|
|
33
|
+
averageLatencyMs,
|
|
34
|
+
},
|
|
35
|
+
targets,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function renderHtml(report) {
|
|
39
|
+
const rows = report.targets.map(renderRow).join("\n");
|
|
40
|
+
const generated = new Date(report.generatedAt).toLocaleString("en-US", {
|
|
41
|
+
dateStyle: "medium",
|
|
42
|
+
timeStyle: "short",
|
|
43
|
+
});
|
|
44
|
+
return `<!doctype html>
|
|
45
|
+
<html lang="en">
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="utf-8" />
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
49
|
+
<title>MegaMCP Sentinel Status</title>
|
|
50
|
+
<style>
|
|
51
|
+
:root{--ink:#13110E;--bone:#EDE8DC;--steel:#6E675C;--molten:#FF4D17;--line:rgba(237,232,220,.16);--panel:#1A1712}
|
|
52
|
+
*{box-sizing:border-box} body{margin:0;background:var(--ink);color:var(--bone);font-family:Inter,system-ui,sans-serif;line-height:1.45}
|
|
53
|
+
main{max-width:1120px;margin:0 auto;padding:40px 24px 70px}
|
|
54
|
+
a{color:inherit}.brand{font-weight:800;letter-spacing:-.04em;font-size:20px}.brand span{background:var(--molten);color:var(--ink);padding:1px 8px;border-radius:5px;margin-left:1px}
|
|
55
|
+
header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;border-bottom:1px solid var(--line);padding-bottom:28px;margin-bottom:28px}
|
|
56
|
+
h1{font-size:clamp(38px,7vw,76px);line-height:.95;letter-spacing:-.06em;margin:28px 0 14px}.sub{color:#A39B8E;max-width:700px;font-size:18px}
|
|
57
|
+
.stamp{font-family:ui-monospace,Menlo,monospace;color:#A39B8E;font-size:13px;text-align:right}.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:28px 0}
|
|
58
|
+
.metric{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px}.metric b{display:block;font-size:28px;letter-spacing:-.04em}.metric span{font-family:ui-monospace,Menlo,monospace;color:#A39B8E;font-size:12px;text-transform:uppercase}
|
|
59
|
+
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--line);vertical-align:top}th{font-family:ui-monospace,Menlo,monospace;color:#A39B8E;font-size:12px;text-transform:uppercase}tr:last-child td{border-bottom:0}
|
|
60
|
+
.status{font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:800;padding:4px 8px;border-radius:5px}.up{background:rgba(70,199,120,.16);color:#7FE0A6}.degraded{background:rgba(255,194,75,.16);color:#FFC24B}.down{background:rgba(255,77,23,.18);color:#FF7A4D}
|
|
61
|
+
.muted{color:#A39B8E}.mono{font-family:ui-monospace,Menlo,monospace;font-size:13px}@media(max-width:800px){header{display:block}.stamp{text-align:left}.grid{grid-template-columns:1fr 1fr}table{display:block;overflow-x:auto}}
|
|
62
|
+
</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<main>
|
|
66
|
+
<header>
|
|
67
|
+
<div>
|
|
68
|
+
<div class="brand">MEGA<span>MCP</span></div>
|
|
69
|
+
<h1>Sentinel status</h1>
|
|
70
|
+
<p class="sub">Protocol-native health checks for MCP servers: initialize, tools/list, latency, tool inventory, and schema drift.</p>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="stamp">Generated<br>${escapeHtml(generated)}</div>
|
|
73
|
+
</header>
|
|
74
|
+
<section class="grid" aria-label="Summary metrics">
|
|
75
|
+
<div class="metric"><b>${report.summary.total}</b><span>targets</span></div>
|
|
76
|
+
<div class="metric"><b>${report.summary.up}</b><span>up</span></div>
|
|
77
|
+
<div class="metric"><b>${report.summary.degraded}</b><span>degraded</span></div>
|
|
78
|
+
<div class="metric"><b>${report.summary.down}</b><span>down</span></div>
|
|
79
|
+
<div class="metric"><b>${report.summary.averageLatencyMs ?? "-"}${report.summary.averageLatencyMs === null ? "" : "ms"}</b><span>avg latency</span></div>
|
|
80
|
+
</section>
|
|
81
|
+
<table>
|
|
82
|
+
<thead><tr><th>Status</th><th>Target</th><th>Tools</th><th>Latency</th><th>Schema</th><th>Notes</th></tr></thead>
|
|
83
|
+
<tbody>${rows}</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
</main>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
function renderRow(r) {
|
|
91
|
+
const drift = r.schema.drift
|
|
92
|
+
? `+${r.schema.added.length} -${r.schema.removed.length} ~${r.schema.changed.length}`
|
|
93
|
+
: "no drift";
|
|
94
|
+
const notes = [...r.notes, r.error ? `Error: ${r.error}` : ""].filter(Boolean).join("; ");
|
|
95
|
+
return `<tr>
|
|
96
|
+
<td><span class="status ${r.status}">${r.status.toUpperCase()}</span></td>
|
|
97
|
+
<td><strong>${escapeHtml(r.label)}</strong><div class="muted mono">${escapeHtml(r.targetId)}</div></td>
|
|
98
|
+
<td class="mono">${r.toolCount}</td>
|
|
99
|
+
<td class="mono">${r.latencyMs.total}ms<div class="muted">connect ${r.latencyMs.connect ?? "-"}ms / list ${r.latencyMs.toolsList ?? "-"}ms</div></td>
|
|
100
|
+
<td class="mono">${escapeHtml(drift)}<div class="muted">${escapeHtml(r.schema.fingerprint || "no fingerprint")}</div></td>
|
|
101
|
+
<td class="muted">${escapeHtml(notes || "clear")}</td>
|
|
102
|
+
</tr>`;
|
|
103
|
+
}
|
|
104
|
+
function escapeHtml(value) {
|
|
105
|
+
return value
|
|
106
|
+
.replaceAll("&", "&")
|
|
107
|
+
.replaceAll("<", "<")
|
|
108
|
+
.replaceAll(">", ">")
|
|
109
|
+
.replaceAll('"', """);
|
|
110
|
+
}
|
|
111
|
+
main().catch((e) => {
|
|
112
|
+
console.error(e instanceof Error ? e.message : e);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A minimal MCP server used as a local, dependency-free monitoring target
|
|
3
|
+
// so Sentinel's full loop can be demonstrated live.
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
const server = new Server({ name: "echo-sample", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
8
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
9
|
+
tools: [
|
|
10
|
+
{
|
|
11
|
+
name: "echo",
|
|
12
|
+
description: "Echo back the provided message.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { message: { type: "string" } },
|
|
16
|
+
required: ["message"],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "ping",
|
|
22
|
+
description: "Health ping.",
|
|
23
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "reverse",
|
|
27
|
+
description: "Reverse the provided string.",
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: { text: { type: "string" } },
|
|
31
|
+
required: ["text"],
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}));
|
|
37
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
38
|
+
const name = req.params.name;
|
|
39
|
+
const args = (req.params.arguments ?? {});
|
|
40
|
+
if (name === "echo") {
|
|
41
|
+
return { content: [{ type: "text", text: String(args.message ?? "") }] };
|
|
42
|
+
}
|
|
43
|
+
if (name === "ping") {
|
|
44
|
+
return { content: [{ type: "text", text: "pong" }] };
|
|
45
|
+
}
|
|
46
|
+
if (name === "reverse") {
|
|
47
|
+
return { content: [{ type: "text", text: String(args.text ?? "").split("").reverse().join("") }] };
|
|
48
|
+
}
|
|
49
|
+
return { content: [{ type: "text", text: `unknown tool ${name}` }], isError: true };
|
|
50
|
+
});
|
|
51
|
+
const transport = new StdioServerTransport();
|
|
52
|
+
await server.connect(transport);
|
|
53
|
+
console.error("echo-sample MCP server running");
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sentinel exposed AS an MCP server — an MCP server that monitors MCP servers.
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { checkTargets, configPath } from "./orchestrate.js";
|
|
8
|
+
import { readState } from "./state.js";
|
|
9
|
+
const server = new Server({ name: "megamcp-sentinel", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
10
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
11
|
+
tools: [
|
|
12
|
+
{
|
|
13
|
+
name: "list_targets",
|
|
14
|
+
description: "List all MCP servers Sentinel is configured to monitor.",
|
|
15
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "check_target",
|
|
19
|
+
description: "Run a live health check against a monitored MCP server (or all of them). " +
|
|
20
|
+
"Returns status, latency, tool count, and schema-drift.",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
targetId: { type: "string", description: "Target id to check; omit to check all." },
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "health_summary",
|
|
31
|
+
description: "Most recent stored status across all monitored MCP servers (no live check).",
|
|
32
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
}));
|
|
36
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
37
|
+
const name = req.params.name;
|
|
38
|
+
const args = (req.params.arguments ?? {});
|
|
39
|
+
try {
|
|
40
|
+
if (name === "list_targets") {
|
|
41
|
+
const { targets } = loadConfig(configPath());
|
|
42
|
+
return text(JSON.stringify(targets.map((t) => ({ id: t.id, label: t.label, transport: t.transport.type })), null, 2));
|
|
43
|
+
}
|
|
44
|
+
if (name === "check_target") {
|
|
45
|
+
const targetId = typeof args.targetId === "string" ? args.targetId : undefined;
|
|
46
|
+
const results = await checkTargets(targetId);
|
|
47
|
+
return text(JSON.stringify(results, null, 2));
|
|
48
|
+
}
|
|
49
|
+
if (name === "health_summary") {
|
|
50
|
+
const { targets } = loadConfig(configPath());
|
|
51
|
+
const summary = targets.map((t) => {
|
|
52
|
+
const st = readState(t.id);
|
|
53
|
+
const last = st?.history?.[st.history.length - 1];
|
|
54
|
+
return {
|
|
55
|
+
id: t.id,
|
|
56
|
+
label: t.label,
|
|
57
|
+
status: last?.status ?? "unknown",
|
|
58
|
+
lastChecked: last?.timestamp ?? null,
|
|
59
|
+
tools: last?.toolCount ?? null,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
return text(JSON.stringify(summary, null, 2));
|
|
63
|
+
}
|
|
64
|
+
return text(`Unknown tool: ${name}`, true);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
return text(`Error: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
function text(s, isError = false) {
|
|
71
|
+
return { content: [{ type: "text", text: s }], isError };
|
|
72
|
+
}
|
|
73
|
+
async function main() {
|
|
74
|
+
const transport = new StdioServerTransport();
|
|
75
|
+
await server.connect(transport);
|
|
76
|
+
console.error("megamcp-sentinel MCP server running on stdio");
|
|
77
|
+
}
|
|
78
|
+
main().catch((e) => {
|
|
79
|
+
console.error(e);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
});
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
const STATE_DIR = resolve(process.cwd(), "state");
|
|
4
|
+
const HISTORY_CAP = 200;
|
|
5
|
+
function fileFor(id) {
|
|
6
|
+
// sanitize id for filesystem safety
|
|
7
|
+
const safe = id.replace(/[^a-z0-9_-]/gi, "_");
|
|
8
|
+
return join(STATE_DIR, `${safe}.json`);
|
|
9
|
+
}
|
|
10
|
+
export function readState(id) {
|
|
11
|
+
const f = fileFor(id);
|
|
12
|
+
if (!existsSync(f))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(f, "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function writeState(state) {
|
|
22
|
+
if (!existsSync(STATE_DIR))
|
|
23
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
24
|
+
const capped = { ...state, history: state.history.slice(-HISTORY_CAP) };
|
|
25
|
+
writeFileSync(fileFor(state.targetId), JSON.stringify(capped, null, 2));
|
|
26
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Running Sentinel in production (Windows)
|
|
2
|
+
|
|
3
|
+
`run-check.ps1` runs every configured check once and appends a timestamped JSON record to `logs/runs.jsonl`. Per-target rolling history is also kept in `state/`.
|
|
4
|
+
|
|
5
|
+
## Enable 24/7 monitoring (every 15 minutes, hidden)
|
|
6
|
+
|
|
7
|
+
Run this once in PowerShell (it sets up a recurring Scheduled Task — OS-level persistence, so it needs your authorization):
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
schtasks /Create /TN "MegaMCP Sentinel" `
|
|
11
|
+
/TR "powershell.exe -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$env:USERPROFILE\Desktop\MegaMCP\sentinel\run-check.ps1`"" `
|
|
12
|
+
/SC MINUTE /MO 15 /F
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Check / run / disable
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
schtasks /Query /TN "MegaMCP Sentinel" /FO LIST # status + next run
|
|
19
|
+
schtasks /Run /TN "MegaMCP Sentinel" # run now
|
|
20
|
+
schtasks /Delete /TN "MegaMCP Sentinel" /F # remove it
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Where results go
|
|
24
|
+
- `logs/runs.jsonl` — one JSON object per run: `{ at, results: [...] }`
|
|
25
|
+
- `state/<target>.json` — rolling per-target history + last-known tool schema (for drift)
|
|
26
|
+
|
|
27
|
+
When the hosted version ships, this same wrapper logic moves to a server-side cron / Vercel Cron / GitHub Action feeding the public status board.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@megamcp/sentinel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MegaMCP Sentinel — an MCP server that monitors the health of other MCP servers.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/",
|
|
9
|
+
"README.md",
|
|
10
|
+
"docs-run-production.md",
|
|
11
|
+
"run-check.ps1",
|
|
12
|
+
"sentinel.config.json"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public",
|
|
16
|
+
"registry": "https://registry.npmjs.org/"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"megamcp-sentinel": "dist/cli.js",
|
|
20
|
+
"megamcp-inspect": "dist/inspect.js",
|
|
21
|
+
"megamcp-sentinel-report": "dist/report.js"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"prepack": "npm run build",
|
|
26
|
+
"start": "node dist/server.js",
|
|
27
|
+
"check": "node dist/cli.js check",
|
|
28
|
+
"watch": "node dist/cli.js watch",
|
|
29
|
+
"inspect": "node dist/inspect.js",
|
|
30
|
+
"report": "node dist/report.js",
|
|
31
|
+
"sample": "node dist/sample-target/echo-server.js"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
35
|
+
"@modelcontextprotocol/server-everything": "^2026.1.26",
|
|
36
|
+
"@modelcontextprotocol/server-memory": "^2026.1.26",
|
|
37
|
+
"zod": "^4.4.3"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.20.0",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/run-check.ps1
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# MegaMCP Sentinel - production check wrapper (for Windows Task Scheduler).
|
|
2
|
+
# Runs all configured checks and appends one timestamped JSON line to logs\runs.jsonl.
|
|
3
|
+
$ErrorActionPreference = 'Continue'
|
|
4
|
+
$node = Join-Path $env:LOCALAPPDATA 'Programs\nodejs'
|
|
5
|
+
$env:Path = "$node;$env:Path"
|
|
6
|
+
$proj = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
|
7
|
+
Set-Location $proj
|
|
8
|
+
New-Item -ItemType Directory -Force (Join-Path $proj 'logs') | Out-Null
|
|
9
|
+
$stamp = (Get-Date).ToString('o')
|
|
10
|
+
$out = (& node 'dist\cli.js' check --json) -join "`n"
|
|
11
|
+
try { $parsed = $out | ConvertFrom-Json } catch { $parsed = @{ error = 'check failed'; raw = $out } }
|
|
12
|
+
$line = [pscustomobject]@{ at = $stamp; results = $parsed } | ConvertTo-Json -Depth 8 -Compress
|
|
13
|
+
Add-Content -Path (Join-Path $proj 'logs\runs.jsonl') -Value $line
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"targets": [
|
|
3
|
+
{
|
|
4
|
+
"id": "echo-sample",
|
|
5
|
+
"label": "Echo Sample (bundled demo)",
|
|
6
|
+
"transport": {
|
|
7
|
+
"type": "stdio",
|
|
8
|
+
"command": "node",
|
|
9
|
+
"args": ["dist/sample-target/echo-server.js"]
|
|
10
|
+
},
|
|
11
|
+
"minTools": 2,
|
|
12
|
+
"latencyBudgetMs": 4000
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "ref-everything",
|
|
16
|
+
"label": "MCP Reference: everything",
|
|
17
|
+
"transport": { "type": "stdio", "command": "node", "args": ["node_modules/@modelcontextprotocol/server-everything/dist/index.js"] },
|
|
18
|
+
"minTools": 1,
|
|
19
|
+
"latencyBudgetMs": 8000,
|
|
20
|
+
"timeoutMs": 40000
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "ref-memory",
|
|
24
|
+
"label": "MCP Reference: memory",
|
|
25
|
+
"transport": { "type": "stdio", "command": "node", "args": ["node_modules/@modelcontextprotocol/server-memory/dist/index.js"] },
|
|
26
|
+
"minTools": 1,
|
|
27
|
+
"latencyBudgetMs": 8000,
|
|
28
|
+
"timeoutMs": 40000
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"_examples": {
|
|
32
|
+
"_comment": "Keys starting with _ are ignored by the loader. Copy these shapes into 'targets'.",
|
|
33
|
+
"http_target": {
|
|
34
|
+
"id": "my-remote-mcp",
|
|
35
|
+
"label": "My Remote MCP (HTTP)",
|
|
36
|
+
"transport": {
|
|
37
|
+
"type": "http",
|
|
38
|
+
"url": "https://example.com/mcp",
|
|
39
|
+
"headers": { "Authorization": "Bearer ${TOKEN}" }
|
|
40
|
+
},
|
|
41
|
+
"minTools": 1,
|
|
42
|
+
"latencyBudgetMs": 5000
|
|
43
|
+
},
|
|
44
|
+
"stdio_target": {
|
|
45
|
+
"id": "some-local-mcp",
|
|
46
|
+
"label": "Some Local MCP (stdio)",
|
|
47
|
+
"transport": { "type": "stdio", "command": "npx", "args": ["-y", "@scope/some-mcp-server"] }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|