@solomonneas/librenms-mcp 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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/index.js +560 -0
- package/dist/mcp-server.js +553 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Solomon Neas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# librenms-mcp
|
|
2
|
+
|
|
3
|
+
MCP server exposing LibreNMS read + safe-write tools via API token auth. Three-tier write gating: reads are open, writes require `confirm: true`, destructive ops would require `confirm: true` + `destructive: true` (v1 ships tier 1 + tier 2 only; tier 3 destructive ops are deferred).
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
**Reads (8):** `librenms_status`, `librenms_list_devices`, `librenms_get_device`, `librenms_list_ports`, `librenms_port_health`, `librenms_list_alerts`, `librenms_get_alert`, `librenms_alert_history`.
|
|
8
|
+
|
|
9
|
+
**Safe writes (2, require `confirm: true`):** `librenms_ack_alert`, `librenms_set_maintenance`.
|
|
10
|
+
|
|
11
|
+
**Destructive (tier 3):** not in v1. Operations like device deletion, alert rule removal, and bulk port resets are intentionally absent until the gate pattern has more field time.
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Set the following env vars. Both credential vars are required.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
LIBRENMS_URL=https://librenms.example.local
|
|
19
|
+
LIBRENMS_TOKEN=<your-api-token>
|
|
20
|
+
|
|
21
|
+
# Optional: skip TLS cert validation (homelab self-signed certs).
|
|
22
|
+
# Accepts true/1/yes (case-insensitive). Defaults to false.
|
|
23
|
+
LIBRENMS_TLS_INSECURE=false
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Trailing slashes on `LIBRENMS_URL` are stripped. The API token is registered with the redactor on startup and masked from all log + error output.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
npm install -g @solomonneas/librenms-mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or run via npx:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
npx -y @solomonneas/librenms-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
### Claude Desktop
|
|
43
|
+
|
|
44
|
+
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"librenms": {
|
|
50
|
+
"command": "npx",
|
|
51
|
+
"args": ["-y", "@solomonneas/librenms-mcp"],
|
|
52
|
+
"env": {
|
|
53
|
+
"LIBRENMS_URL": "https://librenms.example.local",
|
|
54
|
+
"LIBRENMS_TOKEN": "<your-api-token>",
|
|
55
|
+
"LIBRENMS_TLS_INSECURE": "false"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Claude Code
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
claude mcp add librenms -s user -- npx -y @solomonneas/librenms-mcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then export env vars in your shell (`~/.bashrc`, `~/.zshrc`) or pass `--env` flags.
|
|
69
|
+
|
|
70
|
+
### OpenClaw
|
|
71
|
+
|
|
72
|
+
Plugin loads automatically once installed. Config goes in your `~/.openclaw/openclaw.json` `plugins.entries.librenms` (or use the bundled `openclaw.plugin.json`):
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"plugins": {
|
|
77
|
+
"entries": {
|
|
78
|
+
"librenms": {
|
|
79
|
+
"package": "@solomonneas/librenms-mcp",
|
|
80
|
+
"activation": { "onStartup": true }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Env vars from `~/.openclaw/workspace/.env` are inherited by the plugin.
|
|
88
|
+
|
|
89
|
+
### Hermes Agent
|
|
90
|
+
|
|
91
|
+
Add to `~/.config/hermes/agents.yaml`:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
mcp_servers:
|
|
95
|
+
librenms:
|
|
96
|
+
command: npx
|
|
97
|
+
args: ["-y", "@solomonneas/librenms-mcp"]
|
|
98
|
+
env:
|
|
99
|
+
LIBRENMS_URL: https://librenms.example.local
|
|
100
|
+
LIBRENMS_TOKEN: <your-api-token>
|
|
101
|
+
LIBRENMS_TLS_INSECURE: "false"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Codex CLI
|
|
105
|
+
|
|
106
|
+
`~/.codex/config.toml`:
|
|
107
|
+
|
|
108
|
+
```toml
|
|
109
|
+
[mcp_servers.librenms]
|
|
110
|
+
command = "npx"
|
|
111
|
+
args = ["-y", "@solomonneas/librenms-mcp"]
|
|
112
|
+
|
|
113
|
+
[mcp_servers.librenms.env]
|
|
114
|
+
LIBRENMS_URL = "https://librenms.example.local"
|
|
115
|
+
LIBRENMS_TOKEN = "<your-api-token>"
|
|
116
|
+
LIBRENMS_TLS_INSECURE = "false"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Safety
|
|
120
|
+
|
|
121
|
+
This MCP uses the same three-tier write-gating pattern as the rest of the `solomonneas/*-mcp` family:
|
|
122
|
+
|
|
123
|
+
- **Tier 1 (reads):** open. No confirm flag needed. Status, device + port listings, port health, alert listings, alert history.
|
|
124
|
+
- **Tier 2 (safe writes):** require an explicit `confirm: true` arg. The JSON schema documents this on every write tool. Alert acknowledge and device maintenance toggling live here. A hallucinated tool call without the confirm flag throws `WriteGateError` before any HTTP traffic.
|
|
125
|
+
- **Tier 3 (destructive):** not implemented in v1. When added, ops like device deletion, alert rule removal, and bulk port resets will additionally require `destructive: true`. The model cannot bypass either gate from a hallucinated call.
|
|
126
|
+
|
|
127
|
+
**API token scope recommendation:** start with a "Read Only" token role in LibreNMS (Settings > API > New API Token > Read Only) and verify the read tools work end-to-end. Grade up to "Normal User" or "Global Read/Write" only after you've confirmed the redactor is masking your token in your transcripts and that the model is honoring the confirm gate. Tokens can be revoked instantly from the same Settings > API screen.
|
|
128
|
+
|
|
129
|
+
The `LIBRENMS_TLS_INSECURE=true` toggle exists for homelab self-signed certs. Leave it `false` in any environment with a real CA-signed cert.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// index.ts
|
|
4
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
var ConfigError = class extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ConfigError";
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
function isTruthy(value) {
|
|
14
|
+
if (!value) return false;
|
|
15
|
+
return ["true", "1", "yes"].includes(value.toLowerCase());
|
|
16
|
+
}
|
|
17
|
+
function resolveConfig(env) {
|
|
18
|
+
const url = env.LIBRENMS_URL;
|
|
19
|
+
const token = env.LIBRENMS_TOKEN;
|
|
20
|
+
if (!url) throw new ConfigError("LIBRENMS_URL is required");
|
|
21
|
+
if (!token) throw new ConfigError("LIBRENMS_TOKEN is required");
|
|
22
|
+
return {
|
|
23
|
+
url: url.replace(/\/+$/, ""),
|
|
24
|
+
token,
|
|
25
|
+
tlsInsecure: isTruthy(env.LIBRENMS_TLS_INSECURE)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/librenms-client.ts
|
|
30
|
+
import { Agent as UndiciAgent } from "undici";
|
|
31
|
+
var LibreNmsClientError = class extends Error {
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(`LibreNMS ${status}: ${message}`);
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.name = "LibreNmsClientError";
|
|
36
|
+
}
|
|
37
|
+
status;
|
|
38
|
+
};
|
|
39
|
+
var LibreNmsUnreachableError = class extends Error {
|
|
40
|
+
constructor(cause) {
|
|
41
|
+
super(`LibreNMS unreachable: ${cause}`);
|
|
42
|
+
this.name = "LibreNmsUnreachableError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var LibreNmsClient = class {
|
|
46
|
+
constructor(cfg, opts = {}) {
|
|
47
|
+
this.cfg = cfg;
|
|
48
|
+
this.retryDelayMs = opts.retryDelayMs ?? 1e3;
|
|
49
|
+
if (cfg.tlsInsecure && cfg.url.startsWith("https://")) {
|
|
50
|
+
this.dispatcher = new UndiciAgent({ connect: { rejectUnauthorized: false } });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
cfg;
|
|
54
|
+
retryDelayMs;
|
|
55
|
+
// Node's global fetch (undici) ignores node:https.Agent. To actually skip
|
|
56
|
+
// cert verification for self-signed LibreNMS hosts we pass an undici Agent
|
|
57
|
+
// via the `dispatcher` init option.
|
|
58
|
+
dispatcher;
|
|
59
|
+
async get(path) {
|
|
60
|
+
return this.request("GET", path);
|
|
61
|
+
}
|
|
62
|
+
async post(path, body) {
|
|
63
|
+
return this.request("POST", path, body);
|
|
64
|
+
}
|
|
65
|
+
async put(path, body) {
|
|
66
|
+
return this.request("PUT", path, body);
|
|
67
|
+
}
|
|
68
|
+
async request(method, path, body) {
|
|
69
|
+
const url = this.cfg.url + "/api/v0" + path;
|
|
70
|
+
const headers = { "x-auth-token": this.cfg.token };
|
|
71
|
+
let bodyStr;
|
|
72
|
+
if (body !== void 0) {
|
|
73
|
+
headers["content-type"] = "application/json";
|
|
74
|
+
bodyStr = JSON.stringify(body);
|
|
75
|
+
}
|
|
76
|
+
let lastErr;
|
|
77
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
const init = { method, headers, body: bodyStr };
|
|
80
|
+
if (this.dispatcher) init.dispatcher = this.dispatcher;
|
|
81
|
+
const res = await fetch(url, init);
|
|
82
|
+
if (res.status >= 200 && res.status < 300) {
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
if (!text) return void 0;
|
|
85
|
+
return JSON.parse(text);
|
|
86
|
+
}
|
|
87
|
+
if (res.status >= 500) {
|
|
88
|
+
lastErr = new LibreNmsUnreachableError(`HTTP ${res.status}`);
|
|
89
|
+
if (attempt === 0) await sleep(this.retryDelayMs);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const errText = await res.text();
|
|
93
|
+
let msg = errText;
|
|
94
|
+
try {
|
|
95
|
+
msg = JSON.parse(errText).message ?? errText;
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
throw new LibreNmsClientError(res.status, msg);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e instanceof LibreNmsClientError) throw e;
|
|
101
|
+
lastErr = new LibreNmsUnreachableError(e.message);
|
|
102
|
+
if (attempt === 0) await sleep(this.retryDelayMs);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw lastErr ?? new LibreNmsUnreachableError("unknown");
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
function sleep(ms) {
|
|
109
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/security.ts
|
|
113
|
+
var SECRETS = /* @__PURE__ */ new Set();
|
|
114
|
+
var BASE64_TOKEN_RE = /[A-Za-z0-9+/=]{12,}/g;
|
|
115
|
+
function registerSecret(s) {
|
|
116
|
+
if (s && s.length > 0) SECRETS.add(s);
|
|
117
|
+
}
|
|
118
|
+
function maskString(s) {
|
|
119
|
+
let out = s;
|
|
120
|
+
for (const secret of SECRETS) {
|
|
121
|
+
if (out.includes(secret)) out = out.split(secret).join("REDACTED");
|
|
122
|
+
}
|
|
123
|
+
out = out.replace(BASE64_TOKEN_RE, (token) => {
|
|
124
|
+
try {
|
|
125
|
+
const decoded = Buffer.from(token, "base64").toString("utf8");
|
|
126
|
+
for (const secret of SECRETS) {
|
|
127
|
+
if (decoded.includes(secret)) return "REDACTED";
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
return token;
|
|
132
|
+
});
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
function redact(value) {
|
|
136
|
+
if (typeof value === "string") return maskString(value);
|
|
137
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
138
|
+
if (value && typeof value === "object") {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const [k, v] of Object.entries(value)) out[k] = redact(v);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/tools/librenms_status.ts
|
|
147
|
+
import { Type } from "@sinclair/typebox";
|
|
148
|
+
|
|
149
|
+
// src/tools/_util.ts
|
|
150
|
+
function jsonToolResult(payload) {
|
|
151
|
+
return {
|
|
152
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/tools/librenms_status.ts
|
|
157
|
+
var Schema = Type.Object({}, { additionalProperties: false });
|
|
158
|
+
function createLibrenmsStatusTool(getClient) {
|
|
159
|
+
return {
|
|
160
|
+
name: "librenms_status",
|
|
161
|
+
label: "librenms: status",
|
|
162
|
+
description: "LibreNMS system health (version, totals, last poll) via GET /api/v0/system.",
|
|
163
|
+
parameters: Schema,
|
|
164
|
+
execute: async () => {
|
|
165
|
+
const client = getClient();
|
|
166
|
+
const r = await client.get("/system");
|
|
167
|
+
return jsonToolResult({ system: r.system?.[0] ?? null });
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/tools/librenms_list_devices.ts
|
|
173
|
+
import { Type as Type2 } from "@sinclair/typebox";
|
|
174
|
+
var Schema2 = Type2.Object(
|
|
175
|
+
{
|
|
176
|
+
type: Type2.Optional(
|
|
177
|
+
Type2.Union(
|
|
178
|
+
[
|
|
179
|
+
Type2.Literal("all"),
|
|
180
|
+
Type2.Literal("up"),
|
|
181
|
+
Type2.Literal("down"),
|
|
182
|
+
Type2.Literal("ignored"),
|
|
183
|
+
Type2.Literal("disabled")
|
|
184
|
+
],
|
|
185
|
+
{ description: "Device filter type. Default 'all'." }
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
},
|
|
189
|
+
{ additionalProperties: false }
|
|
190
|
+
);
|
|
191
|
+
function createLibrenmsListDevicesTool(getClient) {
|
|
192
|
+
return {
|
|
193
|
+
name: "librenms_list_devices",
|
|
194
|
+
label: "librenms: list devices",
|
|
195
|
+
description: "List devices monitored by LibreNMS via GET /api/v0/devices. Optional type filter (all|up|down|ignored|disabled).",
|
|
196
|
+
parameters: Schema2,
|
|
197
|
+
execute: async (_id, raw) => {
|
|
198
|
+
const args = raw ?? {};
|
|
199
|
+
const path = args.type ? `/devices?type=${args.type}` : "/devices";
|
|
200
|
+
const client = getClient();
|
|
201
|
+
const r = await client.get(path);
|
|
202
|
+
return jsonToolResult({ devices: r.devices ?? [] });
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/tools/librenms_get_device.ts
|
|
208
|
+
import { Type as Type3 } from "@sinclair/typebox";
|
|
209
|
+
var Schema3 = Type3.Object(
|
|
210
|
+
{
|
|
211
|
+
hostname: Type3.String({
|
|
212
|
+
description: "Device hostname or IP as configured in LibreNMS."
|
|
213
|
+
})
|
|
214
|
+
},
|
|
215
|
+
{ additionalProperties: false }
|
|
216
|
+
);
|
|
217
|
+
function createLibrenmsGetDeviceTool(getClient) {
|
|
218
|
+
return {
|
|
219
|
+
name: "librenms_get_device",
|
|
220
|
+
label: "librenms: get device",
|
|
221
|
+
description: "Fetch a single device by hostname via GET /api/v0/devices/{hostname}.",
|
|
222
|
+
parameters: Schema3,
|
|
223
|
+
execute: async (_id, raw) => {
|
|
224
|
+
const args = raw;
|
|
225
|
+
const client = getClient();
|
|
226
|
+
const r = await client.get(`/devices/${encodeURIComponent(args.hostname)}`);
|
|
227
|
+
return jsonToolResult({ device: r.devices?.[0] ?? null });
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/tools/librenms_list_ports.ts
|
|
233
|
+
import { Type as Type4 } from "@sinclair/typebox";
|
|
234
|
+
var Schema4 = Type4.Object(
|
|
235
|
+
{
|
|
236
|
+
hostname: Type4.String({
|
|
237
|
+
description: "Device hostname or IP as configured in LibreNMS."
|
|
238
|
+
})
|
|
239
|
+
},
|
|
240
|
+
{ additionalProperties: false }
|
|
241
|
+
);
|
|
242
|
+
var COLUMNS = "ifName,ifAdminStatus,ifOperStatus,ifInErrors,ifOutErrors,ifSpeed,ifDescr";
|
|
243
|
+
function createLibrenmsListPortsTool(getClient) {
|
|
244
|
+
return {
|
|
245
|
+
name: "librenms_list_ports",
|
|
246
|
+
label: "librenms: list ports",
|
|
247
|
+
description: "List ports on a device via GET /api/v0/devices/{hostname}/ports with a fixed column set (name, status, errors, speed, descr).",
|
|
248
|
+
parameters: Schema4,
|
|
249
|
+
execute: async (_id, raw) => {
|
|
250
|
+
const args = raw;
|
|
251
|
+
const client = getClient();
|
|
252
|
+
const r = await client.get(`/devices/${encodeURIComponent(args.hostname)}/ports?columns=${COLUMNS}`);
|
|
253
|
+
return jsonToolResult({ ports: r.ports ?? [] });
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/tools/librenms_port_health.ts
|
|
259
|
+
import { Type as Type5 } from "@sinclair/typebox";
|
|
260
|
+
var Schema5 = Type5.Object(
|
|
261
|
+
{
|
|
262
|
+
limit: Type5.Optional(
|
|
263
|
+
Type5.Integer({
|
|
264
|
+
minimum: 1,
|
|
265
|
+
description: "Number of top ports to return. Default 10."
|
|
266
|
+
})
|
|
267
|
+
),
|
|
268
|
+
metric: Type5.Optional(
|
|
269
|
+
Type5.Union(
|
|
270
|
+
[
|
|
271
|
+
Type5.Literal("errors_in"),
|
|
272
|
+
Type5.Literal("errors_out"),
|
|
273
|
+
Type5.Literal("utilization")
|
|
274
|
+
],
|
|
275
|
+
{ description: "Ranking metric. Default 'errors_in'." }
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
},
|
|
279
|
+
{ additionalProperties: false }
|
|
280
|
+
);
|
|
281
|
+
var COLUMNS2 = "device_id,ifName,ifInErrors,ifOutErrors,ifSpeed,ifInOctets,ifOutOctets";
|
|
282
|
+
function createLibrenmsPortHealthTool(getClient) {
|
|
283
|
+
return {
|
|
284
|
+
name: "librenms_port_health",
|
|
285
|
+
label: "librenms: port health",
|
|
286
|
+
description: "Rank ports by errors_in (default), errors_out, or utilization via GET /api/v0/ports with client-side sort.",
|
|
287
|
+
parameters: Schema5,
|
|
288
|
+
execute: async (_id, raw) => {
|
|
289
|
+
const args = raw ?? {};
|
|
290
|
+
const limit = args.limit ?? 10;
|
|
291
|
+
const metric = args.metric ?? "errors_in";
|
|
292
|
+
const client = getClient();
|
|
293
|
+
const r = await client.get(`/ports?columns=${COLUMNS2}`);
|
|
294
|
+
const ports = r.ports ?? [];
|
|
295
|
+
const sorted = ports.slice().sort((a, b) => {
|
|
296
|
+
if (metric === "errors_in") {
|
|
297
|
+
return (b.ifInErrors ?? 0) - (a.ifInErrors ?? 0);
|
|
298
|
+
}
|
|
299
|
+
if (metric === "errors_out") {
|
|
300
|
+
return (b.ifOutErrors ?? 0) - (a.ifOutErrors ?? 0);
|
|
301
|
+
}
|
|
302
|
+
const aUtil = a.ifSpeed ? ((a.ifInOctets ?? 0) + (a.ifOutOctets ?? 0)) / a.ifSpeed : 0;
|
|
303
|
+
const bUtil = b.ifSpeed ? ((b.ifInOctets ?? 0) + (b.ifOutOctets ?? 0)) / b.ifSpeed : 0;
|
|
304
|
+
return bUtil - aUtil;
|
|
305
|
+
});
|
|
306
|
+
return jsonToolResult({ metric, limit, top: sorted.slice(0, limit) });
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/tools/librenms_list_alerts.ts
|
|
312
|
+
import { Type as Type6 } from "@sinclair/typebox";
|
|
313
|
+
var Schema6 = Type6.Object(
|
|
314
|
+
{
|
|
315
|
+
state: Type6.Optional(
|
|
316
|
+
Type6.Union(
|
|
317
|
+
[Type6.Literal(0), Type6.Literal(1), Type6.Literal(2)],
|
|
318
|
+
{ description: "Alert state filter: 0=ok, 1=active, 2=ack." }
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
},
|
|
322
|
+
{ additionalProperties: false }
|
|
323
|
+
);
|
|
324
|
+
function createLibrenmsListAlertsTool(getClient) {
|
|
325
|
+
return {
|
|
326
|
+
name: "librenms_list_alerts",
|
|
327
|
+
label: "librenms: list alerts",
|
|
328
|
+
description: "List alerts via GET /api/v0/alerts. Optional state filter (0=ok, 1=active, 2=ack).",
|
|
329
|
+
parameters: Schema6,
|
|
330
|
+
execute: async (_id, raw) => {
|
|
331
|
+
const args = raw ?? {};
|
|
332
|
+
const path = args.state !== void 0 ? `/alerts?state=${args.state}` : "/alerts";
|
|
333
|
+
const client = getClient();
|
|
334
|
+
const r = await client.get(path);
|
|
335
|
+
return jsonToolResult({ alerts: r.alerts ?? [] });
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/tools/librenms_get_alert.ts
|
|
341
|
+
import { Type as Type7 } from "@sinclair/typebox";
|
|
342
|
+
var Schema7 = Type7.Object(
|
|
343
|
+
{
|
|
344
|
+
id: Type7.Integer({ minimum: 1, description: "Alert id." })
|
|
345
|
+
},
|
|
346
|
+
{ additionalProperties: false }
|
|
347
|
+
);
|
|
348
|
+
function createLibrenmsGetAlertTool(getClient) {
|
|
349
|
+
return {
|
|
350
|
+
name: "librenms_get_alert",
|
|
351
|
+
label: "librenms: get alert",
|
|
352
|
+
description: "Fetch a single alert by id via GET /api/v0/alerts/{id}.",
|
|
353
|
+
parameters: Schema7,
|
|
354
|
+
execute: async (_id, raw) => {
|
|
355
|
+
const args = raw;
|
|
356
|
+
const client = getClient();
|
|
357
|
+
const r = await client.get(`/alerts/${args.id}`);
|
|
358
|
+
return jsonToolResult({ alert: r.alerts?.[0] ?? null });
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/tools/librenms_alert_history.ts
|
|
364
|
+
import { Type as Type8 } from "@sinclair/typebox";
|
|
365
|
+
var Schema8 = Type8.Object(
|
|
366
|
+
{
|
|
367
|
+
device_id: Type8.Optional(
|
|
368
|
+
Type8.Integer({
|
|
369
|
+
minimum: 1,
|
|
370
|
+
description: "Optional device id to scope the alert log."
|
|
371
|
+
})
|
|
372
|
+
),
|
|
373
|
+
limit: Type8.Optional(
|
|
374
|
+
Type8.Integer({
|
|
375
|
+
minimum: 1,
|
|
376
|
+
description: "Max number of log entries. Default 25."
|
|
377
|
+
})
|
|
378
|
+
)
|
|
379
|
+
},
|
|
380
|
+
{ additionalProperties: false }
|
|
381
|
+
);
|
|
382
|
+
function createLibrenmsAlertHistoryTool(getClient) {
|
|
383
|
+
return {
|
|
384
|
+
name: "librenms_alert_history",
|
|
385
|
+
label: "librenms: alert history",
|
|
386
|
+
description: "Recent alert log entries via GET /api/v0/logs/alertlog (optionally scoped to a device_id).",
|
|
387
|
+
parameters: Schema8,
|
|
388
|
+
execute: async (_id, raw) => {
|
|
389
|
+
const args = raw ?? {};
|
|
390
|
+
const limit = args.limit ?? 25;
|
|
391
|
+
const path = args.device_id ? `/logs/alertlog/${args.device_id}?limit=${limit}` : `/logs/alertlog?limit=${limit}`;
|
|
392
|
+
const client = getClient();
|
|
393
|
+
const r = await client.get(path);
|
|
394
|
+
return jsonToolResult({
|
|
395
|
+
count: r.logs?.length ?? 0,
|
|
396
|
+
logs: r.logs ?? []
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/tools/librenms_ack_alert.ts
|
|
403
|
+
import { Type as Type9 } from "@sinclair/typebox";
|
|
404
|
+
|
|
405
|
+
// src/gates.ts
|
|
406
|
+
var WriteGateError = class extends Error {
|
|
407
|
+
constructor(message) {
|
|
408
|
+
super(message);
|
|
409
|
+
this.name = "WriteGateError";
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
function assertConfirmedWrite(args, toolName) {
|
|
413
|
+
if (args.confirm !== true) {
|
|
414
|
+
throw new WriteGateError(`${toolName} is a write operation. Pass {"confirm": true} to proceed.`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/tools/librenms_ack_alert.ts
|
|
419
|
+
var Schema9 = Type9.Object(
|
|
420
|
+
{
|
|
421
|
+
id: Type9.Integer({ minimum: 1, description: "Alert id to acknowledge." }),
|
|
422
|
+
note: Type9.Optional(
|
|
423
|
+
Type9.String({ description: "Optional acknowledgement note." })
|
|
424
|
+
),
|
|
425
|
+
until_clear: Type9.Optional(
|
|
426
|
+
Type9.Boolean({
|
|
427
|
+
description: "When true, the ack persists until the alert clears (default LibreNMS behavior: ack until next state change)."
|
|
428
|
+
})
|
|
429
|
+
),
|
|
430
|
+
confirm: Type9.Boolean({
|
|
431
|
+
description: "Must be true to write. Tier-2 safe-write gate."
|
|
432
|
+
})
|
|
433
|
+
},
|
|
434
|
+
{ additionalProperties: false }
|
|
435
|
+
);
|
|
436
|
+
var NAME = "librenms_ack_alert";
|
|
437
|
+
function createLibrenmsAckAlertTool(getClient) {
|
|
438
|
+
return {
|
|
439
|
+
name: NAME,
|
|
440
|
+
label: "librenms: ack alert",
|
|
441
|
+
description: "Acknowledge an active alert by id via PUT /api/v0/alerts/{id}. Tier-2 write; requires confirm:true.",
|
|
442
|
+
parameters: Schema9,
|
|
443
|
+
execute: async (_id, raw) => {
|
|
444
|
+
assertConfirmedWrite(raw, NAME);
|
|
445
|
+
const args = raw;
|
|
446
|
+
const client = getClient();
|
|
447
|
+
const body = {};
|
|
448
|
+
if (args.note !== void 0) body.note = args.note;
|
|
449
|
+
if (args.until_clear !== void 0) body.until_clear = args.until_clear;
|
|
450
|
+
const r = await client.put(`/alerts/${args.id}`, body);
|
|
451
|
+
return jsonToolResult({ alert_id: args.id, acked: true, response: r });
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/tools/librenms_set_maintenance.ts
|
|
457
|
+
import { Type as Type10 } from "@sinclair/typebox";
|
|
458
|
+
var DURATION_RE = /^\d+:\d{2}$/;
|
|
459
|
+
var START_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
|
460
|
+
var Schema10 = Type10.Object(
|
|
461
|
+
{
|
|
462
|
+
hostname: Type10.String({
|
|
463
|
+
description: "Device hostname or IP as configured in LibreNMS."
|
|
464
|
+
}),
|
|
465
|
+
duration: Type10.String({
|
|
466
|
+
description: "Maintenance duration. LibreNMS format `H:i`, e.g. `2:00` for 2 hours or `0:30` for 30 minutes."
|
|
467
|
+
}),
|
|
468
|
+
title: Type10.Optional(
|
|
469
|
+
Type10.String({ description: "Maintenance window title." })
|
|
470
|
+
),
|
|
471
|
+
notes: Type10.Optional(Type10.String({ description: "Free-text notes." })),
|
|
472
|
+
start: Type10.Optional(
|
|
473
|
+
Type10.String({
|
|
474
|
+
description: "Start time. LibreNMS format `Y-m-d H:i:00`, e.g. `2026-05-17 14:30:00`. Default: server now."
|
|
475
|
+
})
|
|
476
|
+
),
|
|
477
|
+
confirm: Type10.Boolean({
|
|
478
|
+
description: "Must be true to write. Tier-2 safe-write gate."
|
|
479
|
+
})
|
|
480
|
+
},
|
|
481
|
+
{ additionalProperties: false }
|
|
482
|
+
);
|
|
483
|
+
var NAME2 = "librenms_set_maintenance";
|
|
484
|
+
function createLibrenmsSetMaintenanceTool(getClient) {
|
|
485
|
+
return {
|
|
486
|
+
name: NAME2,
|
|
487
|
+
label: "librenms: set maintenance",
|
|
488
|
+
description: "Put a device into a maintenance window (suppresses alerts) via POST /api/v0/devices/{hostname}/maintenance. Tier-2 write; requires confirm:true.",
|
|
489
|
+
parameters: Schema10,
|
|
490
|
+
execute: async (_id, raw) => {
|
|
491
|
+
assertConfirmedWrite(raw, NAME2);
|
|
492
|
+
const args = raw;
|
|
493
|
+
if (!DURATION_RE.test(args.duration)) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`duration must be H:i format (e.g. "2:00" or "0:30"), got: ${args.duration}`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
if (args.start && !START_RE.test(args.start)) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`start must be Y-m-d H:i:00 format (e.g. "2026-05-17 14:30:00"), got: ${args.start}`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
const client = getClient();
|
|
504
|
+
const body = { duration: args.duration };
|
|
505
|
+
if (args.title) body.title = args.title;
|
|
506
|
+
if (args.notes) body.notes = args.notes;
|
|
507
|
+
if (args.start) body.start = args.start;
|
|
508
|
+
const r = await client.post(
|
|
509
|
+
`/devices/${encodeURIComponent(args.hostname)}/maintenance`,
|
|
510
|
+
body
|
|
511
|
+
);
|
|
512
|
+
return jsonToolResult({ hostname: args.hostname, maintenance: r });
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// index.ts
|
|
518
|
+
function withRedactedErrors(tool) {
|
|
519
|
+
const orig = tool.execute.bind(tool);
|
|
520
|
+
return {
|
|
521
|
+
...tool,
|
|
522
|
+
execute: async (id, args) => {
|
|
523
|
+
try {
|
|
524
|
+
return await orig(id, args);
|
|
525
|
+
} catch (e) {
|
|
526
|
+
const msg = redact(e.message);
|
|
527
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: msg }) }], isError: true };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function makeFactory(cfg) {
|
|
533
|
+
registerSecret(cfg.token);
|
|
534
|
+
return () => new LibreNmsClient(cfg);
|
|
535
|
+
}
|
|
536
|
+
var index_default = definePluginEntry({
|
|
537
|
+
id: "librenms",
|
|
538
|
+
name: "LibreNMS",
|
|
539
|
+
description: "LibreNMS read + safe-write tools: system status, devices, ports, alerts, ack, maintenance. Single-instance, X-Auth-Token, optional TLS-insecure. Tier-2 writes gated by confirm:true.",
|
|
540
|
+
register(api) {
|
|
541
|
+
if (api.registrationMode !== "full") return;
|
|
542
|
+
const cfg = resolveConfig(process.env);
|
|
543
|
+
const getClient = makeFactory(cfg);
|
|
544
|
+
const register = (t) => api.registerTool(withRedactedErrors(t));
|
|
545
|
+
register(createLibrenmsStatusTool(getClient));
|
|
546
|
+
register(createLibrenmsListDevicesTool(getClient));
|
|
547
|
+
register(createLibrenmsGetDeviceTool(getClient));
|
|
548
|
+
register(createLibrenmsListPortsTool(getClient));
|
|
549
|
+
register(createLibrenmsPortHealthTool(getClient));
|
|
550
|
+
register(createLibrenmsListAlertsTool(getClient));
|
|
551
|
+
register(createLibrenmsGetAlertTool(getClient));
|
|
552
|
+
register(createLibrenmsAlertHistoryTool(getClient));
|
|
553
|
+
register(createLibrenmsAckAlertTool(getClient));
|
|
554
|
+
register(createLibrenmsSetMaintenanceTool(getClient));
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
export {
|
|
558
|
+
index_default as default,
|
|
559
|
+
withRedactedErrors
|
|
560
|
+
};
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// mcp-server.ts
|
|
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
|
+
|
|
8
|
+
// src/config.ts
|
|
9
|
+
var ConfigError = class extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "ConfigError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
function isTruthy(value) {
|
|
16
|
+
if (!value) return false;
|
|
17
|
+
return ["true", "1", "yes"].includes(value.toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
function resolveConfig(env) {
|
|
20
|
+
const url = env.LIBRENMS_URL;
|
|
21
|
+
const token = env.LIBRENMS_TOKEN;
|
|
22
|
+
if (!url) throw new ConfigError("LIBRENMS_URL is required");
|
|
23
|
+
if (!token) throw new ConfigError("LIBRENMS_TOKEN is required");
|
|
24
|
+
return {
|
|
25
|
+
url: url.replace(/\/+$/, ""),
|
|
26
|
+
token,
|
|
27
|
+
tlsInsecure: isTruthy(env.LIBRENMS_TLS_INSECURE)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/librenms-client.ts
|
|
32
|
+
import { Agent as UndiciAgent } from "undici";
|
|
33
|
+
var LibreNmsClientError = class extends Error {
|
|
34
|
+
constructor(status, message) {
|
|
35
|
+
super(`LibreNMS ${status}: ${message}`);
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.name = "LibreNmsClientError";
|
|
38
|
+
}
|
|
39
|
+
status;
|
|
40
|
+
};
|
|
41
|
+
var LibreNmsUnreachableError = class extends Error {
|
|
42
|
+
constructor(cause) {
|
|
43
|
+
super(`LibreNMS unreachable: ${cause}`);
|
|
44
|
+
this.name = "LibreNmsUnreachableError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var LibreNmsClient = class {
|
|
48
|
+
constructor(cfg2, opts = {}) {
|
|
49
|
+
this.cfg = cfg2;
|
|
50
|
+
this.retryDelayMs = opts.retryDelayMs ?? 1e3;
|
|
51
|
+
if (cfg2.tlsInsecure && cfg2.url.startsWith("https://")) {
|
|
52
|
+
this.dispatcher = new UndiciAgent({ connect: { rejectUnauthorized: false } });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
cfg;
|
|
56
|
+
retryDelayMs;
|
|
57
|
+
// Node's global fetch (undici) ignores node:https.Agent. To actually skip
|
|
58
|
+
// cert verification for self-signed LibreNMS hosts we pass an undici Agent
|
|
59
|
+
// via the `dispatcher` init option.
|
|
60
|
+
dispatcher;
|
|
61
|
+
async get(path) {
|
|
62
|
+
return this.request("GET", path);
|
|
63
|
+
}
|
|
64
|
+
async post(path, body) {
|
|
65
|
+
return this.request("POST", path, body);
|
|
66
|
+
}
|
|
67
|
+
async put(path, body) {
|
|
68
|
+
return this.request("PUT", path, body);
|
|
69
|
+
}
|
|
70
|
+
async request(method, path, body) {
|
|
71
|
+
const url = this.cfg.url + "/api/v0" + path;
|
|
72
|
+
const headers = { "x-auth-token": this.cfg.token };
|
|
73
|
+
let bodyStr;
|
|
74
|
+
if (body !== void 0) {
|
|
75
|
+
headers["content-type"] = "application/json";
|
|
76
|
+
bodyStr = JSON.stringify(body);
|
|
77
|
+
}
|
|
78
|
+
let lastErr;
|
|
79
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
const init = { method, headers, body: bodyStr };
|
|
82
|
+
if (this.dispatcher) init.dispatcher = this.dispatcher;
|
|
83
|
+
const res = await fetch(url, init);
|
|
84
|
+
if (res.status >= 200 && res.status < 300) {
|
|
85
|
+
const text = await res.text();
|
|
86
|
+
if (!text) return void 0;
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
if (res.status >= 500) {
|
|
90
|
+
lastErr = new LibreNmsUnreachableError(`HTTP ${res.status}`);
|
|
91
|
+
if (attempt === 0) await sleep(this.retryDelayMs);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const errText = await res.text();
|
|
95
|
+
let msg = errText;
|
|
96
|
+
try {
|
|
97
|
+
msg = JSON.parse(errText).message ?? errText;
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
throw new LibreNmsClientError(res.status, msg);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e instanceof LibreNmsClientError) throw e;
|
|
103
|
+
lastErr = new LibreNmsUnreachableError(e.message);
|
|
104
|
+
if (attempt === 0) await sleep(this.retryDelayMs);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw lastErr ?? new LibreNmsUnreachableError("unknown");
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
function sleep(ms) {
|
|
111
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/security.ts
|
|
115
|
+
var SECRETS = /* @__PURE__ */ new Set();
|
|
116
|
+
var BASE64_TOKEN_RE = /[A-Za-z0-9+/=]{12,}/g;
|
|
117
|
+
function registerSecret(s) {
|
|
118
|
+
if (s && s.length > 0) SECRETS.add(s);
|
|
119
|
+
}
|
|
120
|
+
function maskString(s) {
|
|
121
|
+
let out = s;
|
|
122
|
+
for (const secret of SECRETS) {
|
|
123
|
+
if (out.includes(secret)) out = out.split(secret).join("REDACTED");
|
|
124
|
+
}
|
|
125
|
+
out = out.replace(BASE64_TOKEN_RE, (token) => {
|
|
126
|
+
try {
|
|
127
|
+
const decoded = Buffer.from(token, "base64").toString("utf8");
|
|
128
|
+
for (const secret of SECRETS) {
|
|
129
|
+
if (decoded.includes(secret)) return "REDACTED";
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
return token;
|
|
134
|
+
});
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
function redact(value) {
|
|
138
|
+
if (typeof value === "string") return maskString(value);
|
|
139
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
140
|
+
if (value && typeof value === "object") {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const [k, v] of Object.entries(value)) out[k] = redact(v);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/tools/librenms_status.ts
|
|
149
|
+
import { Type } from "@sinclair/typebox";
|
|
150
|
+
|
|
151
|
+
// src/tools/_util.ts
|
|
152
|
+
function jsonToolResult(payload) {
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/tools/librenms_status.ts
|
|
159
|
+
var Schema = Type.Object({}, { additionalProperties: false });
|
|
160
|
+
function createLibrenmsStatusTool(getClient2) {
|
|
161
|
+
return {
|
|
162
|
+
name: "librenms_status",
|
|
163
|
+
label: "librenms: status",
|
|
164
|
+
description: "LibreNMS system health (version, totals, last poll) via GET /api/v0/system.",
|
|
165
|
+
parameters: Schema,
|
|
166
|
+
execute: async () => {
|
|
167
|
+
const client = getClient2();
|
|
168
|
+
const r = await client.get("/system");
|
|
169
|
+
return jsonToolResult({ system: r.system?.[0] ?? null });
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/tools/librenms_list_devices.ts
|
|
175
|
+
import { Type as Type2 } from "@sinclair/typebox";
|
|
176
|
+
var Schema2 = Type2.Object(
|
|
177
|
+
{
|
|
178
|
+
type: Type2.Optional(
|
|
179
|
+
Type2.Union(
|
|
180
|
+
[
|
|
181
|
+
Type2.Literal("all"),
|
|
182
|
+
Type2.Literal("up"),
|
|
183
|
+
Type2.Literal("down"),
|
|
184
|
+
Type2.Literal("ignored"),
|
|
185
|
+
Type2.Literal("disabled")
|
|
186
|
+
],
|
|
187
|
+
{ description: "Device filter type. Default 'all'." }
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
{ additionalProperties: false }
|
|
192
|
+
);
|
|
193
|
+
function createLibrenmsListDevicesTool(getClient2) {
|
|
194
|
+
return {
|
|
195
|
+
name: "librenms_list_devices",
|
|
196
|
+
label: "librenms: list devices",
|
|
197
|
+
description: "List devices monitored by LibreNMS via GET /api/v0/devices. Optional type filter (all|up|down|ignored|disabled).",
|
|
198
|
+
parameters: Schema2,
|
|
199
|
+
execute: async (_id, raw) => {
|
|
200
|
+
const args = raw ?? {};
|
|
201
|
+
const path = args.type ? `/devices?type=${args.type}` : "/devices";
|
|
202
|
+
const client = getClient2();
|
|
203
|
+
const r = await client.get(path);
|
|
204
|
+
return jsonToolResult({ devices: r.devices ?? [] });
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/tools/librenms_get_device.ts
|
|
210
|
+
import { Type as Type3 } from "@sinclair/typebox";
|
|
211
|
+
var Schema3 = Type3.Object(
|
|
212
|
+
{
|
|
213
|
+
hostname: Type3.String({
|
|
214
|
+
description: "Device hostname or IP as configured in LibreNMS."
|
|
215
|
+
})
|
|
216
|
+
},
|
|
217
|
+
{ additionalProperties: false }
|
|
218
|
+
);
|
|
219
|
+
function createLibrenmsGetDeviceTool(getClient2) {
|
|
220
|
+
return {
|
|
221
|
+
name: "librenms_get_device",
|
|
222
|
+
label: "librenms: get device",
|
|
223
|
+
description: "Fetch a single device by hostname via GET /api/v0/devices/{hostname}.",
|
|
224
|
+
parameters: Schema3,
|
|
225
|
+
execute: async (_id, raw) => {
|
|
226
|
+
const args = raw;
|
|
227
|
+
const client = getClient2();
|
|
228
|
+
const r = await client.get(`/devices/${encodeURIComponent(args.hostname)}`);
|
|
229
|
+
return jsonToolResult({ device: r.devices?.[0] ?? null });
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/tools/librenms_list_ports.ts
|
|
235
|
+
import { Type as Type4 } from "@sinclair/typebox";
|
|
236
|
+
var Schema4 = Type4.Object(
|
|
237
|
+
{
|
|
238
|
+
hostname: Type4.String({
|
|
239
|
+
description: "Device hostname or IP as configured in LibreNMS."
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
{ additionalProperties: false }
|
|
243
|
+
);
|
|
244
|
+
var COLUMNS = "ifName,ifAdminStatus,ifOperStatus,ifInErrors,ifOutErrors,ifSpeed,ifDescr";
|
|
245
|
+
function createLibrenmsListPortsTool(getClient2) {
|
|
246
|
+
return {
|
|
247
|
+
name: "librenms_list_ports",
|
|
248
|
+
label: "librenms: list ports",
|
|
249
|
+
description: "List ports on a device via GET /api/v0/devices/{hostname}/ports with a fixed column set (name, status, errors, speed, descr).",
|
|
250
|
+
parameters: Schema4,
|
|
251
|
+
execute: async (_id, raw) => {
|
|
252
|
+
const args = raw;
|
|
253
|
+
const client = getClient2();
|
|
254
|
+
const r = await client.get(`/devices/${encodeURIComponent(args.hostname)}/ports?columns=${COLUMNS}`);
|
|
255
|
+
return jsonToolResult({ ports: r.ports ?? [] });
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/tools/librenms_port_health.ts
|
|
261
|
+
import { Type as Type5 } from "@sinclair/typebox";
|
|
262
|
+
var Schema5 = Type5.Object(
|
|
263
|
+
{
|
|
264
|
+
limit: Type5.Optional(
|
|
265
|
+
Type5.Integer({
|
|
266
|
+
minimum: 1,
|
|
267
|
+
description: "Number of top ports to return. Default 10."
|
|
268
|
+
})
|
|
269
|
+
),
|
|
270
|
+
metric: Type5.Optional(
|
|
271
|
+
Type5.Union(
|
|
272
|
+
[
|
|
273
|
+
Type5.Literal("errors_in"),
|
|
274
|
+
Type5.Literal("errors_out"),
|
|
275
|
+
Type5.Literal("utilization")
|
|
276
|
+
],
|
|
277
|
+
{ description: "Ranking metric. Default 'errors_in'." }
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
},
|
|
281
|
+
{ additionalProperties: false }
|
|
282
|
+
);
|
|
283
|
+
var COLUMNS2 = "device_id,ifName,ifInErrors,ifOutErrors,ifSpeed,ifInOctets,ifOutOctets";
|
|
284
|
+
function createLibrenmsPortHealthTool(getClient2) {
|
|
285
|
+
return {
|
|
286
|
+
name: "librenms_port_health",
|
|
287
|
+
label: "librenms: port health",
|
|
288
|
+
description: "Rank ports by errors_in (default), errors_out, or utilization via GET /api/v0/ports with client-side sort.",
|
|
289
|
+
parameters: Schema5,
|
|
290
|
+
execute: async (_id, raw) => {
|
|
291
|
+
const args = raw ?? {};
|
|
292
|
+
const limit = args.limit ?? 10;
|
|
293
|
+
const metric = args.metric ?? "errors_in";
|
|
294
|
+
const client = getClient2();
|
|
295
|
+
const r = await client.get(`/ports?columns=${COLUMNS2}`);
|
|
296
|
+
const ports = r.ports ?? [];
|
|
297
|
+
const sorted = ports.slice().sort((a, b) => {
|
|
298
|
+
if (metric === "errors_in") {
|
|
299
|
+
return (b.ifInErrors ?? 0) - (a.ifInErrors ?? 0);
|
|
300
|
+
}
|
|
301
|
+
if (metric === "errors_out") {
|
|
302
|
+
return (b.ifOutErrors ?? 0) - (a.ifOutErrors ?? 0);
|
|
303
|
+
}
|
|
304
|
+
const aUtil = a.ifSpeed ? ((a.ifInOctets ?? 0) + (a.ifOutOctets ?? 0)) / a.ifSpeed : 0;
|
|
305
|
+
const bUtil = b.ifSpeed ? ((b.ifInOctets ?? 0) + (b.ifOutOctets ?? 0)) / b.ifSpeed : 0;
|
|
306
|
+
return bUtil - aUtil;
|
|
307
|
+
});
|
|
308
|
+
return jsonToolResult({ metric, limit, top: sorted.slice(0, limit) });
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/tools/librenms_list_alerts.ts
|
|
314
|
+
import { Type as Type6 } from "@sinclair/typebox";
|
|
315
|
+
var Schema6 = Type6.Object(
|
|
316
|
+
{
|
|
317
|
+
state: Type6.Optional(
|
|
318
|
+
Type6.Union(
|
|
319
|
+
[Type6.Literal(0), Type6.Literal(1), Type6.Literal(2)],
|
|
320
|
+
{ description: "Alert state filter: 0=ok, 1=active, 2=ack." }
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
},
|
|
324
|
+
{ additionalProperties: false }
|
|
325
|
+
);
|
|
326
|
+
function createLibrenmsListAlertsTool(getClient2) {
|
|
327
|
+
return {
|
|
328
|
+
name: "librenms_list_alerts",
|
|
329
|
+
label: "librenms: list alerts",
|
|
330
|
+
description: "List alerts via GET /api/v0/alerts. Optional state filter (0=ok, 1=active, 2=ack).",
|
|
331
|
+
parameters: Schema6,
|
|
332
|
+
execute: async (_id, raw) => {
|
|
333
|
+
const args = raw ?? {};
|
|
334
|
+
const path = args.state !== void 0 ? `/alerts?state=${args.state}` : "/alerts";
|
|
335
|
+
const client = getClient2();
|
|
336
|
+
const r = await client.get(path);
|
|
337
|
+
return jsonToolResult({ alerts: r.alerts ?? [] });
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/tools/librenms_get_alert.ts
|
|
343
|
+
import { Type as Type7 } from "@sinclair/typebox";
|
|
344
|
+
var Schema7 = Type7.Object(
|
|
345
|
+
{
|
|
346
|
+
id: Type7.Integer({ minimum: 1, description: "Alert id." })
|
|
347
|
+
},
|
|
348
|
+
{ additionalProperties: false }
|
|
349
|
+
);
|
|
350
|
+
function createLibrenmsGetAlertTool(getClient2) {
|
|
351
|
+
return {
|
|
352
|
+
name: "librenms_get_alert",
|
|
353
|
+
label: "librenms: get alert",
|
|
354
|
+
description: "Fetch a single alert by id via GET /api/v0/alerts/{id}.",
|
|
355
|
+
parameters: Schema7,
|
|
356
|
+
execute: async (_id, raw) => {
|
|
357
|
+
const args = raw;
|
|
358
|
+
const client = getClient2();
|
|
359
|
+
const r = await client.get(`/alerts/${args.id}`);
|
|
360
|
+
return jsonToolResult({ alert: r.alerts?.[0] ?? null });
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/tools/librenms_alert_history.ts
|
|
366
|
+
import { Type as Type8 } from "@sinclair/typebox";
|
|
367
|
+
var Schema8 = Type8.Object(
|
|
368
|
+
{
|
|
369
|
+
device_id: Type8.Optional(
|
|
370
|
+
Type8.Integer({
|
|
371
|
+
minimum: 1,
|
|
372
|
+
description: "Optional device id to scope the alert log."
|
|
373
|
+
})
|
|
374
|
+
),
|
|
375
|
+
limit: Type8.Optional(
|
|
376
|
+
Type8.Integer({
|
|
377
|
+
minimum: 1,
|
|
378
|
+
description: "Max number of log entries. Default 25."
|
|
379
|
+
})
|
|
380
|
+
)
|
|
381
|
+
},
|
|
382
|
+
{ additionalProperties: false }
|
|
383
|
+
);
|
|
384
|
+
function createLibrenmsAlertHistoryTool(getClient2) {
|
|
385
|
+
return {
|
|
386
|
+
name: "librenms_alert_history",
|
|
387
|
+
label: "librenms: alert history",
|
|
388
|
+
description: "Recent alert log entries via GET /api/v0/logs/alertlog (optionally scoped to a device_id).",
|
|
389
|
+
parameters: Schema8,
|
|
390
|
+
execute: async (_id, raw) => {
|
|
391
|
+
const args = raw ?? {};
|
|
392
|
+
const limit = args.limit ?? 25;
|
|
393
|
+
const path = args.device_id ? `/logs/alertlog/${args.device_id}?limit=${limit}` : `/logs/alertlog?limit=${limit}`;
|
|
394
|
+
const client = getClient2();
|
|
395
|
+
const r = await client.get(path);
|
|
396
|
+
return jsonToolResult({
|
|
397
|
+
count: r.logs?.length ?? 0,
|
|
398
|
+
logs: r.logs ?? []
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/tools/librenms_ack_alert.ts
|
|
405
|
+
import { Type as Type9 } from "@sinclair/typebox";
|
|
406
|
+
|
|
407
|
+
// src/gates.ts
|
|
408
|
+
var WriteGateError = class extends Error {
|
|
409
|
+
constructor(message) {
|
|
410
|
+
super(message);
|
|
411
|
+
this.name = "WriteGateError";
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
function assertConfirmedWrite(args, toolName) {
|
|
415
|
+
if (args.confirm !== true) {
|
|
416
|
+
throw new WriteGateError(`${toolName} is a write operation. Pass {"confirm": true} to proceed.`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/tools/librenms_ack_alert.ts
|
|
421
|
+
var Schema9 = Type9.Object(
|
|
422
|
+
{
|
|
423
|
+
id: Type9.Integer({ minimum: 1, description: "Alert id to acknowledge." }),
|
|
424
|
+
note: Type9.Optional(
|
|
425
|
+
Type9.String({ description: "Optional acknowledgement note." })
|
|
426
|
+
),
|
|
427
|
+
until_clear: Type9.Optional(
|
|
428
|
+
Type9.Boolean({
|
|
429
|
+
description: "When true, the ack persists until the alert clears (default LibreNMS behavior: ack until next state change)."
|
|
430
|
+
})
|
|
431
|
+
),
|
|
432
|
+
confirm: Type9.Boolean({
|
|
433
|
+
description: "Must be true to write. Tier-2 safe-write gate."
|
|
434
|
+
})
|
|
435
|
+
},
|
|
436
|
+
{ additionalProperties: false }
|
|
437
|
+
);
|
|
438
|
+
var NAME = "librenms_ack_alert";
|
|
439
|
+
function createLibrenmsAckAlertTool(getClient2) {
|
|
440
|
+
return {
|
|
441
|
+
name: NAME,
|
|
442
|
+
label: "librenms: ack alert",
|
|
443
|
+
description: "Acknowledge an active alert by id via PUT /api/v0/alerts/{id}. Tier-2 write; requires confirm:true.",
|
|
444
|
+
parameters: Schema9,
|
|
445
|
+
execute: async (_id, raw) => {
|
|
446
|
+
assertConfirmedWrite(raw, NAME);
|
|
447
|
+
const args = raw;
|
|
448
|
+
const client = getClient2();
|
|
449
|
+
const body = {};
|
|
450
|
+
if (args.note !== void 0) body.note = args.note;
|
|
451
|
+
if (args.until_clear !== void 0) body.until_clear = args.until_clear;
|
|
452
|
+
const r = await client.put(`/alerts/${args.id}`, body);
|
|
453
|
+
return jsonToolResult({ alert_id: args.id, acked: true, response: r });
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/tools/librenms_set_maintenance.ts
|
|
459
|
+
import { Type as Type10 } from "@sinclair/typebox";
|
|
460
|
+
var DURATION_RE = /^\d+:\d{2}$/;
|
|
461
|
+
var START_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
|
462
|
+
var Schema10 = Type10.Object(
|
|
463
|
+
{
|
|
464
|
+
hostname: Type10.String({
|
|
465
|
+
description: "Device hostname or IP as configured in LibreNMS."
|
|
466
|
+
}),
|
|
467
|
+
duration: Type10.String({
|
|
468
|
+
description: "Maintenance duration. LibreNMS format `H:i`, e.g. `2:00` for 2 hours or `0:30` for 30 minutes."
|
|
469
|
+
}),
|
|
470
|
+
title: Type10.Optional(
|
|
471
|
+
Type10.String({ description: "Maintenance window title." })
|
|
472
|
+
),
|
|
473
|
+
notes: Type10.Optional(Type10.String({ description: "Free-text notes." })),
|
|
474
|
+
start: Type10.Optional(
|
|
475
|
+
Type10.String({
|
|
476
|
+
description: "Start time. LibreNMS format `Y-m-d H:i:00`, e.g. `2026-05-17 14:30:00`. Default: server now."
|
|
477
|
+
})
|
|
478
|
+
),
|
|
479
|
+
confirm: Type10.Boolean({
|
|
480
|
+
description: "Must be true to write. Tier-2 safe-write gate."
|
|
481
|
+
})
|
|
482
|
+
},
|
|
483
|
+
{ additionalProperties: false }
|
|
484
|
+
);
|
|
485
|
+
var NAME2 = "librenms_set_maintenance";
|
|
486
|
+
function createLibrenmsSetMaintenanceTool(getClient2) {
|
|
487
|
+
return {
|
|
488
|
+
name: NAME2,
|
|
489
|
+
label: "librenms: set maintenance",
|
|
490
|
+
description: "Put a device into a maintenance window (suppresses alerts) via POST /api/v0/devices/{hostname}/maintenance. Tier-2 write; requires confirm:true.",
|
|
491
|
+
parameters: Schema10,
|
|
492
|
+
execute: async (_id, raw) => {
|
|
493
|
+
assertConfirmedWrite(raw, NAME2);
|
|
494
|
+
const args = raw;
|
|
495
|
+
if (!DURATION_RE.test(args.duration)) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`duration must be H:i format (e.g. "2:00" or "0:30"), got: ${args.duration}`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (args.start && !START_RE.test(args.start)) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`start must be Y-m-d H:i:00 format (e.g. "2026-05-17 14:30:00"), got: ${args.start}`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const client = getClient2();
|
|
506
|
+
const body = { duration: args.duration };
|
|
507
|
+
if (args.title) body.title = args.title;
|
|
508
|
+
if (args.notes) body.notes = args.notes;
|
|
509
|
+
if (args.start) body.start = args.start;
|
|
510
|
+
const r = await client.post(
|
|
511
|
+
`/devices/${encodeURIComponent(args.hostname)}/maintenance`,
|
|
512
|
+
body
|
|
513
|
+
);
|
|
514
|
+
return jsonToolResult({ hostname: args.hostname, maintenance: r });
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// mcp-server.ts
|
|
520
|
+
var cfg = resolveConfig(process.env);
|
|
521
|
+
registerSecret(cfg.token);
|
|
522
|
+
var getClient = () => new LibreNmsClient(cfg);
|
|
523
|
+
var tools = [
|
|
524
|
+
createLibrenmsStatusTool(getClient),
|
|
525
|
+
createLibrenmsListDevicesTool(getClient),
|
|
526
|
+
createLibrenmsGetDeviceTool(getClient),
|
|
527
|
+
createLibrenmsListPortsTool(getClient),
|
|
528
|
+
createLibrenmsPortHealthTool(getClient),
|
|
529
|
+
createLibrenmsListAlertsTool(getClient),
|
|
530
|
+
createLibrenmsGetAlertTool(getClient),
|
|
531
|
+
createLibrenmsAlertHistoryTool(getClient),
|
|
532
|
+
createLibrenmsAckAlertTool(getClient),
|
|
533
|
+
createLibrenmsSetMaintenanceTool(getClient)
|
|
534
|
+
];
|
|
535
|
+
var toolMap = new Map(tools.map((t) => [t.name, t]));
|
|
536
|
+
var server = new Server({ name: "librenms-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
537
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
538
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.parameters }))
|
|
539
|
+
}));
|
|
540
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
541
|
+
const t = toolMap.get(req.params.name);
|
|
542
|
+
if (!t) {
|
|
543
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `unknown tool: ${req.params.name}` }) }], isError: true };
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
return await t.execute(req.params.name, req.params.arguments ?? {});
|
|
547
|
+
} catch (e) {
|
|
548
|
+
const msg = redact(e.message);
|
|
549
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: msg }) }], isError: true };
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
var transport = new StdioServerTransport();
|
|
553
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "librenms",
|
|
4
|
+
"name": "LibreNMS",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "LibreNMS read + safe-write tools: devices, ports, alerts, ack, maintenance.",
|
|
7
|
+
"entry": "./dist/index.js",
|
|
8
|
+
"activation": { "onStartup": true },
|
|
9
|
+
"compat": { "openclaw": ">=2026.4.22" },
|
|
10
|
+
"permissions": [],
|
|
11
|
+
"configSchema": { "type": "object", "properties": {}, "additionalProperties": false }
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@solomonneas/librenms-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server exposing LibreNMS read + safe-write tools",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"openclaw": {
|
|
7
|
+
"extensions": ["./index.ts"],
|
|
8
|
+
"compat": {
|
|
9
|
+
"pluginApi": ">=2026.3.24-beta.2",
|
|
10
|
+
"minGatewayVersion": "2026.3.24-beta.2"
|
|
11
|
+
},
|
|
12
|
+
"build": {
|
|
13
|
+
"openclawVersion": "2026.5.17",
|
|
14
|
+
"pluginSdkVersion": "2026.5.17"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"bin": { "librenms-mcp": "./dist/mcp-server.js" },
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"start": "node dist/mcp-server.js",
|
|
22
|
+
"dev": "tsx mcp-server.ts",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
26
|
+
},
|
|
27
|
+
"files": ["dist", "openclaw.plugin.json", "README.md", "LICENSE"],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
30
|
+
"@sinclair/typebox": "^0.34.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": { "openclaw": "^2026.4.22" },
|
|
33
|
+
"peerDependenciesMeta": { "openclaw": { "optional": true } },
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^25.6.2",
|
|
36
|
+
"openclaw": "^2026.4.22",
|
|
37
|
+
"tsup": "^8.4.0",
|
|
38
|
+
"tsx": "^4.19.0",
|
|
39
|
+
"typescript": "^6.0.3",
|
|
40
|
+
"vitest": "^4.1.5"
|
|
41
|
+
},
|
|
42
|
+
"engines": { "node": ">=20" },
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": { "type": "git", "url": "https://github.com/solomonneas/librenms-mcp" }
|
|
45
|
+
}
|