mcp-librenms 0.1.0__tar.gz

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.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-librenms
3
+ Version: 0.1.0
4
+ Summary: MCP server that exposes the LibreNMS REST API as MCP tools
5
+ License: MIT
6
+ Keywords: mcp,librenms,monitoring,snmp,network
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Topic :: System :: Monitoring
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: fastmcp>=2.0.0
12
+ Requires-Dist: httpx>=0.27.0
13
+ Requires-Dist: pyyaml>=6.0
14
+
15
+ # mcp-librenms
16
+
17
+ MCP (Model Context Protocol) server that exposes the [LibreNMS](https://www.librenms.org/) REST API as MCP tools. Built with [FastMCP](https://github.com/jlowin/fastmcp). Connect AI assistants (Claude Desktop, GitHub Copilot, etc.) to your LibreNMS instance to query and manage monitored devices, ports, alerts, logs, and more.
18
+
19
+ ## Features
20
+
21
+ ~40 tools covering:
22
+
23
+ | Category | Tools |
24
+ |---|---|
25
+ | **System** | `ping`, `system_info` |
26
+ | **Devices** | `list_devices`, `get_device`, `add_device`, `delete_device`, `update_device_field`, `rename_device`, `discover_device`, `get_device_ports`, `get_device_ip_addresses`, `get_device_availability`, `get_device_outages`, `get_device_groups`, `get_device_components`, `get_device_graphs`, `get_device_maintenance`, `set_device_maintenance`, `add_device_eventlog` |
27
+ | **Alerts** | `list_alerts`, `get_alert`, `ack_alert`, `unmute_alert`, `list_alert_rules`, `get_alert_rule`, `delete_alert_rule` |
28
+ | **Ports** | `get_all_ports`, `search_ports`, `get_port_info`, `get_port_ip_info`, `ports_with_mac`, `update_port_description` |
29
+ | **Logs** | `list_eventlog`, `list_syslog`, `list_alertlog`, `list_authlog` |
30
+ | **Locations** | `list_locations`, `get_location`, `add_location`, `delete_location`, `edit_location` |
31
+ | **Sensors** | `list_sensors` |
32
+ | **Device groups** | `list_devicegroups`, `get_devicegroup` |
33
+ | **ARP** | `list_arp` |
34
+ | **Services** | `list_services` |
35
+ | **Inventory** | `get_inventory` |
36
+
37
+ ## Requirements
38
+
39
+ - Python 3.9+
40
+ - A LibreNMS instance with API access
41
+ - An API token (LibreNMS web UI: **Settings > API > API Settings**)
42
+
43
+ ## Setup
44
+
45
+ ```bash
46
+ # 1. Create and activate a virtual environment
47
+ python -m venv .venv
48
+ .venv\Scripts\activate # Windows
49
+ source .venv/bin/activate # Linux/macOS
50
+
51
+ # 2. Install the package (editable)
52
+ pip install -e .
53
+
54
+ # 3. Configure
55
+ copy config.example.yaml config.yaml # Windows
56
+ # cp config.example.yaml config.yaml # Linux/macOS
57
+ # then edit config.yaml with your LibreNMS URL and API token
58
+ ```
59
+
60
+ ### Configuration
61
+
62
+ `config.yaml` (gitignored — never commit it):
63
+
64
+ ```yaml
65
+ librenms:
66
+ url: "http://your-librenms-host" # base URL, no trailing slash
67
+ token: "your-api-token" # LibreNMS web UI: Settings > API > API Settings
68
+ ```
69
+
70
+ ## Running
71
+
72
+ The server speaks MCP over **streamable HTTP** by default, bound to `0.0.0.0:5757`:
73
+
74
+ ```bash
75
+ # Console script (foreground)
76
+ mcp-librenms --config config.yaml
77
+
78
+ # Or as a module
79
+ python -m mcp_librenms --config config.yaml
80
+ ```
81
+
82
+ Endpoint: **http://\<IP\>:5757/mcp**
83
+
84
+ ### Options
85
+
86
+ | Flag | Default | Description |
87
+ |---|---|---|
88
+ | `--config FILE` | `config.yaml` | Path to YAML config file (CWD, then project root) |
89
+ | `--host IP` | `0.0.0.0` | Bind address |
90
+ | `--port PORT` | `5757` | Bind port |
91
+ | `--daemon` | off | Run in background (detached process) |
92
+ | `--stdio` | off | Use stdio transport instead of HTTP |
93
+
94
+ Examples:
95
+
96
+ ```bash
97
+ # Background on a custom address/port
98
+ mcp-librenms --config config.yaml --daemon --host 192.168.1.10 --port 8080
99
+ # → http://192.168.1.10:8080/mcp
100
+
101
+ # stdio transport (for MCP clients that spawn the server)
102
+ mcp-librenms --config config.yaml --stdio
103
+ ```
104
+
105
+ ### MCP client configuration
106
+
107
+ Example for any MCP client that supports streamable HTTP servers:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "librenms": {
113
+ "url": "http://your-librenms-host:5757/mcp"
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ For stdio-based clients (Claude Desktop, etc.), spawn the server with `--stdio` and point `--config` at your config file:
120
+
121
+ ```json
122
+ {
123
+ "mcpServers": {
124
+ "librenms": {
125
+ "command": "D:\\path\\to\\.venv\\Scripts\\mcp-librenms.exe",
126
+ "args": ["--config", "D:\\path\\to\\mcp-librenms\\config.yaml", "--stdio"]
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ > **Note:** the client uses `verify=False` for TLS, so it works with LibreNMS instances behind self-signed certificates.
133
+
134
+ ## Project structure
135
+
136
+ ```
137
+ mcp-librenms/
138
+ ├── config.example.yaml # Template for local configuration
139
+ ├── config.yaml # Your local configuration (gitignored)
140
+ ├── .gitignore
141
+ ├── README.md
142
+ ├── pyproject.toml # Package metadata + console script
143
+ ├── requirements.txt # Flat dependency list
144
+ └── mcp_librenms/
145
+ ├── __init__.py
146
+ ├── __main__.py # python -m mcp_librenms
147
+ ├── client.py # LibreNMS REST API HTTP client
148
+ └── server.py # MCP server + tool definitions
149
+ ```
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,139 @@
1
+ # mcp-librenms
2
+
3
+ MCP (Model Context Protocol) server that exposes the [LibreNMS](https://www.librenms.org/) REST API as MCP tools. Built with [FastMCP](https://github.com/jlowin/fastmcp). Connect AI assistants (Claude Desktop, GitHub Copilot, etc.) to your LibreNMS instance to query and manage monitored devices, ports, alerts, logs, and more.
4
+
5
+ ## Features
6
+
7
+ ~40 tools covering:
8
+
9
+ | Category | Tools |
10
+ |---|---|
11
+ | **System** | `ping`, `system_info` |
12
+ | **Devices** | `list_devices`, `get_device`, `add_device`, `delete_device`, `update_device_field`, `rename_device`, `discover_device`, `get_device_ports`, `get_device_ip_addresses`, `get_device_availability`, `get_device_outages`, `get_device_groups`, `get_device_components`, `get_device_graphs`, `get_device_maintenance`, `set_device_maintenance`, `add_device_eventlog` |
13
+ | **Alerts** | `list_alerts`, `get_alert`, `ack_alert`, `unmute_alert`, `list_alert_rules`, `get_alert_rule`, `delete_alert_rule` |
14
+ | **Ports** | `get_all_ports`, `search_ports`, `get_port_info`, `get_port_ip_info`, `ports_with_mac`, `update_port_description` |
15
+ | **Logs** | `list_eventlog`, `list_syslog`, `list_alertlog`, `list_authlog` |
16
+ | **Locations** | `list_locations`, `get_location`, `add_location`, `delete_location`, `edit_location` |
17
+ | **Sensors** | `list_sensors` |
18
+ | **Device groups** | `list_devicegroups`, `get_devicegroup` |
19
+ | **ARP** | `list_arp` |
20
+ | **Services** | `list_services` |
21
+ | **Inventory** | `get_inventory` |
22
+
23
+ ## Requirements
24
+
25
+ - Python 3.9+
26
+ - A LibreNMS instance with API access
27
+ - An API token (LibreNMS web UI: **Settings > API > API Settings**)
28
+
29
+ ## Setup
30
+
31
+ ```bash
32
+ # 1. Create and activate a virtual environment
33
+ python -m venv .venv
34
+ .venv\Scripts\activate # Windows
35
+ source .venv/bin/activate # Linux/macOS
36
+
37
+ # 2. Install the package (editable)
38
+ pip install -e .
39
+
40
+ # 3. Configure
41
+ copy config.example.yaml config.yaml # Windows
42
+ # cp config.example.yaml config.yaml # Linux/macOS
43
+ # then edit config.yaml with your LibreNMS URL and API token
44
+ ```
45
+
46
+ ### Configuration
47
+
48
+ `config.yaml` (gitignored — never commit it):
49
+
50
+ ```yaml
51
+ librenms:
52
+ url: "http://your-librenms-host" # base URL, no trailing slash
53
+ token: "your-api-token" # LibreNMS web UI: Settings > API > API Settings
54
+ ```
55
+
56
+ ## Running
57
+
58
+ The server speaks MCP over **streamable HTTP** by default, bound to `0.0.0.0:5757`:
59
+
60
+ ```bash
61
+ # Console script (foreground)
62
+ mcp-librenms --config config.yaml
63
+
64
+ # Or as a module
65
+ python -m mcp_librenms --config config.yaml
66
+ ```
67
+
68
+ Endpoint: **http://\<IP\>:5757/mcp**
69
+
70
+ ### Options
71
+
72
+ | Flag | Default | Description |
73
+ |---|---|---|
74
+ | `--config FILE` | `config.yaml` | Path to YAML config file (CWD, then project root) |
75
+ | `--host IP` | `0.0.0.0` | Bind address |
76
+ | `--port PORT` | `5757` | Bind port |
77
+ | `--daemon` | off | Run in background (detached process) |
78
+ | `--stdio` | off | Use stdio transport instead of HTTP |
79
+
80
+ Examples:
81
+
82
+ ```bash
83
+ # Background on a custom address/port
84
+ mcp-librenms --config config.yaml --daemon --host 192.168.1.10 --port 8080
85
+ # → http://192.168.1.10:8080/mcp
86
+
87
+ # stdio transport (for MCP clients that spawn the server)
88
+ mcp-librenms --config config.yaml --stdio
89
+ ```
90
+
91
+ ### MCP client configuration
92
+
93
+ Example for any MCP client that supports streamable HTTP servers:
94
+
95
+ ```json
96
+ {
97
+ "mcpServers": {
98
+ "librenms": {
99
+ "url": "http://your-librenms-host:5757/mcp"
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ For stdio-based clients (Claude Desktop, etc.), spawn the server with `--stdio` and point `--config` at your config file:
106
+
107
+ ```json
108
+ {
109
+ "mcpServers": {
110
+ "librenms": {
111
+ "command": "D:\\path\\to\\.venv\\Scripts\\mcp-librenms.exe",
112
+ "args": ["--config", "D:\\path\\to\\mcp-librenms\\config.yaml", "--stdio"]
113
+ }
114
+ }
115
+ }
116
+ ```
117
+
118
+ > **Note:** the client uses `verify=False` for TLS, so it works with LibreNMS instances behind self-signed certificates.
119
+
120
+ ## Project structure
121
+
122
+ ```
123
+ mcp-librenms/
124
+ ├── config.example.yaml # Template for local configuration
125
+ ├── config.yaml # Your local configuration (gitignored)
126
+ ├── .gitignore
127
+ ├── README.md
128
+ ├── pyproject.toml # Package metadata + console script
129
+ ├── requirements.txt # Flat dependency list
130
+ └── mcp_librenms/
131
+ ├── __init__.py
132
+ ├── __main__.py # python -m mcp_librenms
133
+ ├── client.py # LibreNMS REST API HTTP client
134
+ └── server.py # MCP server + tool definitions
135
+ ```
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,3 @@
1
+ """mcp-librenms — MCP server exposing the LibreNMS API as MCP tools."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running the server with `python -m mcp_librenms`."""
2
+
3
+ from mcp_librenms.server import run
4
+
5
+ if __name__ == "__main__":
6
+ run()
@@ -0,0 +1,211 @@
1
+ """LibreNMS API HTTP client."""
2
+
3
+ import httpx
4
+ from typing import Any, Optional
5
+
6
+
7
+ class LibreNMSClient:
8
+ def __init__(self, base_url: str, token: str):
9
+ if not base_url or not token:
10
+ raise ValueError(
11
+ "LibreNMS base_url and token are required "
12
+ "(set them in the config file, see config.example.yaml)"
13
+ )
14
+ self.base_url = base_url.rstrip("/")
15
+ self.token = token
16
+ self.api_base = f"{self.base_url}/api/v0"
17
+ self.headers = {"X-Auth-Token": self.token, "Content-Type": "application/json"}
18
+
19
+ def _get(self, path: str, params: Optional[dict] = None) -> dict:
20
+ url = f"{self.api_base}{path}"
21
+ with httpx.Client(verify=False, timeout=30) as client:
22
+ resp = client.get(url, headers=self.headers, params=params)
23
+ resp.raise_for_status()
24
+ return resp.json()
25
+
26
+ def _post(self, path: str, data: Optional[dict] = None) -> dict:
27
+ url = f"{self.api_base}{path}"
28
+ with httpx.Client(verify=False, timeout=30) as client:
29
+ resp = client.post(url, headers=self.headers, json=data or {})
30
+ resp.raise_for_status()
31
+ return resp.json()
32
+
33
+ def _put(self, path: str, data: Optional[dict] = None) -> dict:
34
+ url = f"{self.api_base}{path}"
35
+ with httpx.Client(verify=False, timeout=30) as client:
36
+ resp = client.put(url, headers=self.headers, json=data or {})
37
+ resp.raise_for_status()
38
+ return resp.json()
39
+
40
+ def _patch(self, path: str, data: Optional[dict] = None) -> dict:
41
+ url = f"{self.api_base}{path}"
42
+ with httpx.Client(verify=False, timeout=30) as client:
43
+ resp = client.patch(url, headers=self.headers, json=data or {})
44
+ resp.raise_for_status()
45
+ return resp.json()
46
+
47
+ def _delete(self, path: str) -> dict:
48
+ url = f"{self.api_base}{path}"
49
+ with httpx.Client(verify=False, timeout=30) as client:
50
+ resp = client.delete(url, headers=self.headers)
51
+ resp.raise_for_status()
52
+ return resp.json()
53
+
54
+ # ── System ────────────────────────────────────────────────────────────────
55
+ def ping(self) -> dict:
56
+ return self._get("/ping")
57
+
58
+ def system_info(self) -> dict:
59
+ return self._get("/system")
60
+
61
+ # ── Devices ───────────────────────────────────────────────────────────────
62
+ def list_devices(self, **params) -> dict:
63
+ return self._get("/devices", params=params or None)
64
+
65
+ def get_device(self, hostname: str) -> dict:
66
+ return self._get(f"/devices/{hostname}")
67
+
68
+ def add_device(self, data: dict) -> dict:
69
+ return self._post("/devices", data)
70
+
71
+ def delete_device(self, hostname: str) -> dict:
72
+ return self._delete(f"/devices/{hostname}")
73
+
74
+ def update_device_field(self, hostname: str, field: str, data: dict) -> dict:
75
+ return self._patch(f"/devices/{hostname}", {"field": field, **data})
76
+
77
+ def rename_device(self, hostname: str, new_hostname: str) -> dict:
78
+ return self._patch(f"/devices/{hostname}/rename/{new_hostname}")
79
+
80
+ def discover_device(self, hostname: str) -> dict:
81
+ return self._get(f"/devices/{hostname}/discover")
82
+
83
+ def get_device_ports(self, hostname: str, **params) -> dict:
84
+ return self._get(f"/devices/{hostname}/ports", params=params or None)
85
+
86
+ def get_device_ip_addresses(self, hostname: str) -> dict:
87
+ return self._get(f"/devices/{hostname}/ip")
88
+
89
+ def get_device_availability(self, hostname: str) -> dict:
90
+ return self._get(f"/devices/{hostname}/availability")
91
+
92
+ def get_device_outages(self, hostname: str) -> dict:
93
+ return self._get(f"/devices/{hostname}/outages")
94
+
95
+ def get_device_groups(self, hostname: str) -> dict:
96
+ return self._get(f"/devices/{hostname}/groups")
97
+
98
+ def get_device_graphs(self, hostname: str) -> dict:
99
+ return self._get(f"/devices/{hostname}/graphs")
100
+
101
+ def get_device_components(self, hostname: str) -> dict:
102
+ return self._get(f"/devices/{hostname}/components")
103
+
104
+ def get_device_maintenance(self, hostname: str) -> dict:
105
+ return self._get(f"/devices/{hostname}/maintenance")
106
+
107
+ def set_device_maintenance(self, hostname: str, data: dict) -> dict:
108
+ return self._post(f"/devices/{hostname}/maintenance", data)
109
+
110
+ def add_device_eventlog(self, hostname: str, text: str, severity: str = "2") -> dict:
111
+ return self._post(f"/devices/{hostname}/eventlog", {"text": text, "severity": severity})
112
+
113
+ # ── Alerts ────────────────────────────────────────────────────────────────
114
+ def list_alerts(self, **params) -> dict:
115
+ return self._get("/alerts", params=params or None)
116
+
117
+ def get_alert(self, alert_id: int) -> dict:
118
+ return self._get(f"/alerts/{alert_id}")
119
+
120
+ def ack_alert(self, alert_id: int) -> dict:
121
+ return self._put(f"/alerts/{alert_id}")
122
+
123
+ def unmute_alert(self, alert_id: int) -> dict:
124
+ return self._put(f"/alerts/unmute/{alert_id}")
125
+
126
+ def list_alert_rules(self) -> dict:
127
+ return self._get("/rules")
128
+
129
+ def get_alert_rule(self, rule_id: int) -> dict:
130
+ return self._get(f"/rules/{rule_id}")
131
+
132
+ def delete_alert_rule(self, rule_id: int) -> dict:
133
+ return self._delete(f"/rules/{rule_id}")
134
+
135
+ # ── Ports ─────────────────────────────────────────────────────────────────
136
+ def get_all_ports(self, columns: Optional[str] = None) -> dict:
137
+ params = {"columns": columns} if columns else None
138
+ return self._get("/ports", params=params)
139
+
140
+ def search_ports(self, field: str, search: str, columns: Optional[str] = None) -> dict:
141
+ params = {"columns": columns} if columns else None
142
+ return self._get(f"/ports/search/{field}/{search}", params=params)
143
+
144
+ def get_port_info(self, port_id: int) -> dict:
145
+ return self._get(f"/ports/{port_id}")
146
+
147
+ def get_port_ip_info(self, port_id: int) -> dict:
148
+ return self._get(f"/ports/{port_id}/ip")
149
+
150
+ def ports_with_mac(self, mac: str) -> dict:
151
+ return self._get(f"/ports/mac/{mac}")
152
+
153
+ def update_port_description(self, port_id: int, description: str) -> dict:
154
+ return self._patch(f"/ports/{port_id}/description", {"description": description})
155
+
156
+ # ── Logs ──────────────────────────────────────────────────────────────────
157
+ def list_eventlog(self, hostname: Optional[str] = None, **params) -> dict:
158
+ path = f"/logs/eventlog/{hostname}" if hostname else "/logs/eventlog"
159
+ return self._get(path, params=params or None)
160
+
161
+ def list_syslog(self, hostname: Optional[str] = None, **params) -> dict:
162
+ path = f"/logs/syslog/{hostname}" if hostname else "/logs/syslog"
163
+ return self._get(path, params=params or None)
164
+
165
+ def list_alertlog(self, hostname: Optional[str] = None, **params) -> dict:
166
+ path = f"/logs/alertlog/{hostname}" if hostname else "/logs/alertlog"
167
+ return self._get(path, params=params or None)
168
+
169
+ def list_authlog(self, **params) -> dict:
170
+ return self._get("/logs/authlog", params=params or None)
171
+
172
+ # ── Locations ─────────────────────────────────────────────────────────────
173
+ def list_locations(self) -> dict:
174
+ return self._get("/resources/locations")
175
+
176
+ def get_location(self, location: str) -> dict:
177
+ return self._get(f"/location/{location}")
178
+
179
+ def add_location(self, data: dict) -> dict:
180
+ return self._post("/locations", data)
181
+
182
+ def delete_location(self, location: str) -> dict:
183
+ return self._delete(f"/locations/{location}")
184
+
185
+ def edit_location(self, location: str, data: dict) -> dict:
186
+ return self._patch(f"/locations/{location}", data)
187
+
188
+ # ── Sensors ───────────────────────────────────────────────────────────────
189
+ def list_sensors(self) -> dict:
190
+ return self._get("/resources/sensors")
191
+
192
+ # ── Device Groups ─────────────────────────────────────────────────────────
193
+ def list_devicegroups(self) -> dict:
194
+ return self._get("/devicegroups")
195
+
196
+ def get_devicegroup(self, name: str) -> dict:
197
+ return self._get(f"/devicegroups/{name}")
198
+
199
+ # ── ARP ───────────────────────────────────────────────────────────────────
200
+ def list_arp(self, query: str, **params) -> dict:
201
+ return self._get(f"/resources/ip/arp/{query}", params=params or None)
202
+
203
+ # ── Services ──────────────────────────────────────────────────────────────
204
+ def list_services(self, hostname: Optional[str] = None) -> dict:
205
+ if hostname:
206
+ return self._get(f"/services/{hostname}")
207
+ return self._get("/services")
208
+
209
+ # ── Inventory ─────────────────────────────────────────────────────────────
210
+ def get_inventory(self, hostname: str, **params) -> dict:
211
+ return self._get(f"/inventory/{hostname}", params=params or None)
@@ -0,0 +1,582 @@
1
+ """LibreNMS MCP Server — exposes LibreNMS API operations as MCP tools (FastMCP)."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import yaml
12
+ from fastmcp import FastMCP
13
+
14
+ mcp = FastMCP("librenms-mcp")
15
+
16
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
17
+
18
+ # Lazy-initialised client so the config is loaded before the first tool call
19
+ _client = None
20
+ _config: dict = {}
21
+
22
+
23
+ def _resolve_config(path: str) -> Path:
24
+ """Resolve a (possibly relative) config path: CWD first, then project root."""
25
+ p = Path(path)
26
+ if not p.is_absolute():
27
+ cwd_candidate = Path.cwd() / p
28
+ if cwd_candidate.exists():
29
+ return cwd_candidate
30
+ return PROJECT_ROOT / p
31
+ return p
32
+
33
+
34
+ def load_config(path: str) -> dict:
35
+ """Load and validate the YAML config file."""
36
+ p = _resolve_config(path)
37
+ if not p.exists():
38
+ raise FileNotFoundError(
39
+ f"Config file not found: {p} "
40
+ f"(copy config.example.yaml to config.yaml and fill in your values)"
41
+ )
42
+ with open(p, "r", encoding="utf-8") as f:
43
+ data = yaml.safe_load(f) or {}
44
+ lib = data.get("librenms") or {}
45
+ url = str(lib.get("url") or "").strip()
46
+ token = str(lib.get("token") or "").strip()
47
+ if not url or not token:
48
+ raise ValueError(
49
+ f"Config file {p} must define non-empty librenms.url and librenms.token"
50
+ )
51
+ return {"url": url, "token": token}
52
+
53
+
54
+ def client():
55
+ global _client
56
+ if _client is None:
57
+ from mcp_librenms.client import LibreNMSClient
58
+ _client = LibreNMSClient(_config["url"], _config["token"])
59
+ return _client
60
+
61
+
62
+ def _json(data) -> str:
63
+ return json.dumps(data, indent=2, ensure_ascii=False)
64
+
65
+
66
+ # ── System ────────────────────────────────────────────────────────────────────
67
+
68
+ @mcp.tool
69
+ def ping() -> str:
70
+ """Check LibreNMS API availability. Sends exactly 3 ping requests (not continuous)
71
+ and reports the result of each."""
72
+ c = client()
73
+ results = []
74
+ for i in range(1, 4):
75
+ try:
76
+ results.append({"ping": i, "status": "ok", "response": c.ping()})
77
+ except Exception as e:
78
+ results.append({"ping": i, "status": "fail", "error": str(e)})
79
+ return _json({
80
+ "pings": results,
81
+ "success": sum(1 for r in results if r["status"] == "ok"),
82
+ "total": len(results),
83
+ })
84
+
85
+
86
+ @mcp.tool
87
+ def system_info() -> str:
88
+ """Display LibreNMS instance information (version, PHP, DB, RRDtool, etc.)."""
89
+ return _json(client().system_info())
90
+
91
+
92
+ # ── Devices ───────────────────────────────────────────────────────────────────
93
+
94
+ @mcp.tool
95
+ def list_devices(
96
+ type: Optional[str] = None,
97
+ os: Optional[str] = None,
98
+ mac: Optional[str] = None,
99
+ ipv4: Optional[str] = None,
100
+ ipv6: Optional[str] = None,
101
+ hostname: Optional[str] = None,
102
+ sysName: Optional[str] = None,
103
+ location: Optional[str] = None,
104
+ status: Optional[int] = None,
105
+ ) -> str:
106
+ """List all devices monitored by LibreNMS.
107
+
108
+ Optional filters: type (router/switch/etc.), os, mac, ipv4, ipv6, hostname,
109
+ sysName, location, status (0=down, 1=up).
110
+ """
111
+ params = {
112
+ "type": type, "os": os, "mac": mac, "ipv4": ipv4, "ipv6": ipv6,
113
+ "hostname": hostname, "sysName": sysName, "location": location,
114
+ "status": status,
115
+ }
116
+ return _json(client().list_devices(**{k: v for k, v in params.items() if v is not None}))
117
+
118
+
119
+ @mcp.tool
120
+ def get_device(hostname: str) -> str:
121
+ """Get detailed information about a specific device by hostname or device ID."""
122
+ return _json(client().get_device(hostname))
123
+
124
+
125
+ @mcp.tool
126
+ def add_device(
127
+ hostname: str,
128
+ snmpver: Optional[str] = None,
129
+ community: Optional[str] = None,
130
+ port: Optional[int] = None,
131
+ transport: Optional[str] = None,
132
+ authlevel: Optional[str] = None,
133
+ authname: Optional[str] = None,
134
+ authpass: Optional[str] = None,
135
+ authalgo: Optional[str] = None,
136
+ cryptopass: Optional[str] = None,
137
+ cryptoalgo: Optional[str] = None,
138
+ force_add: Optional[bool] = None,
139
+ ) -> str:
140
+ """Add a new device to LibreNMS monitoring.
141
+
142
+ Required: hostname. Common optional fields: snmpver (v1/v2c/v3), community
143
+ (SNMPv1/v2c), authlevel, authname, authpass, authalgo, cryptopass, cryptoalgo
144
+ (SNMPv3), port, transport (udp/tcp/udp6/tcp6), force_add.
145
+ """
146
+ data = {
147
+ "hostname": hostname, "snmpver": snmpver, "community": community,
148
+ "port": port, "transport": transport, "authlevel": authlevel,
149
+ "authname": authname, "authpass": authpass, "authalgo": authalgo,
150
+ "cryptopass": cryptopass, "cryptoalgo": cryptoalgo, "force_add": force_add,
151
+ }
152
+ return _json(client().add_device({k: v for k, v in data.items() if v is not None}))
153
+
154
+
155
+ @mcp.tool
156
+ def delete_device(hostname: str) -> str:
157
+ """Remove a device from LibreNMS monitoring."""
158
+ return _json(client().delete_device(hostname))
159
+
160
+
161
+ @mcp.tool
162
+ def update_device_field(hostname: str, field: str, data: dict) -> str:
163
+ """Update a field on a device (e.g. location, notes, ignore, disable, etc.)."""
164
+ return _json(client().update_device_field(hostname, field, data))
165
+
166
+
167
+ @mcp.tool
168
+ def rename_device(hostname: str, new_hostname: str) -> str:
169
+ """Rename a device to a new hostname."""
170
+ return _json(client().rename_device(hostname, new_hostname))
171
+
172
+
173
+ @mcp.tool
174
+ def discover_device(hostname: str) -> str:
175
+ """Trigger immediate SNMP discovery for a device."""
176
+ return _json(client().discover_device(hostname))
177
+
178
+
179
+ @mcp.tool
180
+ def get_device_ports(hostname: str, columns: Optional[str] = None) -> str:
181
+ """List all network ports/interfaces for a device.
182
+
183
+ Optional: columns — comma-separated column names to return.
184
+ """
185
+ params = {"columns": columns} if columns else {}
186
+ return _json(client().get_device_ports(hostname, **params))
187
+
188
+
189
+ @mcp.tool
190
+ def get_device_ip_addresses(hostname: str) -> str:
191
+ """Get all IP addresses (v4 and v6) assigned to a device."""
192
+ return _json(client().get_device_ip_addresses(hostname))
193
+
194
+
195
+ @mcp.tool
196
+ def get_device_availability(hostname: str) -> str:
197
+ """Get uptime/availability statistics for a device."""
198
+ return _json(client().get_device_availability(hostname))
199
+
200
+
201
+ @mcp.tool
202
+ def get_device_outages(hostname: str) -> str:
203
+ """Get outage history for a device."""
204
+ return _json(client().get_device_outages(hostname))
205
+
206
+
207
+ @mcp.tool
208
+ def get_device_groups(hostname: str) -> str:
209
+ """List device groups that a specific device belongs to."""
210
+ return _json(client().get_device_groups(hostname))
211
+
212
+
213
+ @mcp.tool
214
+ def get_device_components(hostname: str) -> str:
215
+ """List hardware/software components discovered on a device."""
216
+ return _json(client().get_device_components(hostname))
217
+
218
+
219
+ @mcp.tool
220
+ def get_device_graphs(hostname: str) -> str:
221
+ """List available graphs for a device."""
222
+ return _json(client().get_device_graphs(hostname))
223
+
224
+
225
+ @mcp.tool
226
+ def get_device_maintenance(hostname: str) -> str:
227
+ """Get current maintenance schedule status for a device."""
228
+ return _json(client().get_device_maintenance(hostname))
229
+
230
+
231
+ @mcp.tool
232
+ def set_device_maintenance(
233
+ hostname: str,
234
+ duration: str,
235
+ title: Optional[str] = None,
236
+ notes: Optional[str] = None,
237
+ start: Optional[str] = None,
238
+ ) -> str:
239
+ """Put a device into maintenance mode.
240
+
241
+ Required: duration (format H:i e.g. '02:00').
242
+ Optional: title, notes, start (format 'Y-m-d H:i:00').
243
+ """
244
+ data = {"duration": duration}
245
+ for key, value in (("title", title), ("notes", notes), ("start", start)):
246
+ if value is not None:
247
+ data[key] = value
248
+ return _json(client().set_device_maintenance(hostname, data))
249
+
250
+
251
+ @mcp.tool
252
+ def add_device_eventlog(hostname: str, text: str, severity: str = "2") -> str:
253
+ """Add a custom event log entry for a device.
254
+
255
+ Severity: 1=ok, 2=info, 3=notice, 4=warning, 5=error.
256
+ """
257
+ return _json(client().add_device_eventlog(hostname, text, severity))
258
+
259
+
260
+ # ── Alerts ────────────────────────────────────────────────────────────────────
261
+
262
+ @mcp.tool
263
+ def list_alerts(
264
+ state: Optional[int] = None,
265
+ severity: Optional[str] = None,
266
+ alert_rule_id: Optional[int] = None,
267
+ ) -> str:
268
+ """List all alerts.
269
+
270
+ Optional filters: state (0=ok, 1=alert, 2=acknowledged),
271
+ severity (ok/warning/critical), alert_rule_id.
272
+ """
273
+ params = {
274
+ "state": state, "severity": severity, "alert_rule_id": alert_rule_id,
275
+ }
276
+ return _json(client().list_alerts(**{k: v for k, v in params.items() if v is not None}))
277
+
278
+
279
+ @mcp.tool
280
+ def get_alert(alert_id: int) -> str:
281
+ """Get details for a specific alert by ID."""
282
+ return _json(client().get_alert(alert_id))
283
+
284
+
285
+ @mcp.tool
286
+ def ack_alert(alert_id: int) -> str:
287
+ """Acknowledge an active alert to suppress further notifications."""
288
+ return _json(client().ack_alert(alert_id))
289
+
290
+
291
+ @mcp.tool
292
+ def unmute_alert(alert_id: int) -> str:
293
+ """Unmute a muted/acknowledged alert so it can fire again."""
294
+ return _json(client().unmute_alert(alert_id))
295
+
296
+
297
+ @mcp.tool
298
+ def list_alert_rules() -> str:
299
+ """List all configured alert rules."""
300
+ return _json(client().list_alert_rules())
301
+
302
+
303
+ @mcp.tool
304
+ def get_alert_rule(rule_id: int) -> str:
305
+ """Get details for a specific alert rule by ID."""
306
+ return _json(client().get_alert_rule(rule_id))
307
+
308
+
309
+ @mcp.tool
310
+ def delete_alert_rule(rule_id: int) -> str:
311
+ """Delete an alert rule by ID."""
312
+ return _json(client().delete_alert_rule(rule_id))
313
+
314
+
315
+ # ── Ports ─────────────────────────────────────────────────────────────────────
316
+
317
+ @mcp.tool
318
+ def get_all_ports(columns: Optional[str] = None) -> str:
319
+ """Get info for all ports across all devices.
320
+
321
+ Use 'columns' to limit returned fields, e.g. 'ifName,port_id,device_id'.
322
+ """
323
+ return _json(client().get_all_ports(columns))
324
+
325
+
326
+ @mcp.tool
327
+ def search_ports(field: str, search: str, columns: Optional[str] = None) -> str:
328
+ """Search for ports matching a string across specified fields.
329
+
330
+ field: comma-separated field(s) to search, e.g. 'ifAlias,ifDescr,ifName'.
331
+ """
332
+ return _json(client().search_ports(field, search, columns))
333
+
334
+
335
+ @mcp.tool
336
+ def get_port_info(port_id: int) -> str:
337
+ """Get all information for a specific port by port ID."""
338
+ return _json(client().get_port_info(port_id))
339
+
340
+
341
+ @mcp.tool
342
+ def get_port_ip_info(port_id: int) -> str:
343
+ """Get all IP addresses (v4 and v6) for a specific port by port ID."""
344
+ return _json(client().get_port_ip_info(port_id))
345
+
346
+
347
+ @mcp.tool
348
+ def ports_with_mac(mac: str) -> str:
349
+ """Find ports associated with a specific MAC address (various formats accepted)."""
350
+ return _json(client().ports_with_mac(mac))
351
+
352
+
353
+ @mcp.tool
354
+ def update_port_description(port_id: int, description: str) -> str:
355
+ """Update the ifAlias/description for a port. Send empty string to reset to default."""
356
+ return _json(client().update_port_description(port_id, description))
357
+
358
+
359
+ # ── Logs ──────────────────────────────────────────────────────────────────────
360
+
361
+ @mcp.tool
362
+ def list_eventlog(
363
+ hostname: Optional[str] = None,
364
+ start: Optional[int] = None,
365
+ limit: Optional[int] = None,
366
+ from_: Optional[str] = None,
367
+ to: Optional[str] = None,
368
+ sortorder: Optional[str] = None,
369
+ ) -> str:
370
+ """List event log entries.
371
+
372
+ Optional: hostname to filter by device. Params: start (page), limit,
373
+ from_ (start datetime or event_id), to, sortorder (ASC/DESC).
374
+ """
375
+ params = {
376
+ "start": start, "limit": limit, "from": from_, "to": to,
377
+ "sortorder": sortorder,
378
+ }
379
+ return _json(client().list_eventlog(hostname, **{k: v for k, v in params.items() if v is not None}))
380
+
381
+
382
+ @mcp.tool
383
+ def list_syslog(
384
+ hostname: Optional[str] = None,
385
+ start: Optional[int] = None,
386
+ limit: Optional[int] = None,
387
+ from_: Optional[str] = None,
388
+ to: Optional[str] = None,
389
+ sortorder: Optional[str] = None,
390
+ ) -> str:
391
+ """List syslog entries.
392
+
393
+ Optional: hostname to filter by device. Supports same params as list_eventlog.
394
+ """
395
+ params = {
396
+ "start": start, "limit": limit, "from": from_, "to": to,
397
+ "sortorder": sortorder,
398
+ }
399
+ return _json(client().list_syslog(hostname, **{k: v for k, v in params.items() if v is not None}))
400
+
401
+
402
+ @mcp.tool
403
+ def list_alertlog(
404
+ hostname: Optional[str] = None,
405
+ start: Optional[int] = None,
406
+ limit: Optional[int] = None,
407
+ from_: Optional[str] = None,
408
+ to: Optional[str] = None,
409
+ ) -> str:
410
+ """List alert log entries. Optional: hostname to filter by device."""
411
+ params = {"start": start, "limit": limit, "from": from_, "to": to}
412
+ return _json(client().list_alertlog(hostname, **{k: v for k, v in params.items() if v is not None}))
413
+
414
+
415
+ @mcp.tool
416
+ def list_authlog(start: Optional[int] = None, limit: Optional[int] = None) -> str:
417
+ """List authentication log entries."""
418
+ params = {"start": start, "limit": limit}
419
+ return _json(client().list_authlog(**{k: v for k, v in params.items() if v is not None}))
420
+
421
+
422
+ # ── Locations ─────────────────────────────────────────────────────────────────
423
+
424
+ @mcp.tool
425
+ def list_locations() -> str:
426
+ """List all configured locations with coordinates."""
427
+ return _json(client().list_locations())
428
+
429
+
430
+ @mcp.tool
431
+ def get_location(location: str) -> str:
432
+ """Get details for a specific location by name or ID."""
433
+ return _json(client().get_location(location))
434
+
435
+
436
+ @mcp.tool
437
+ def add_location(
438
+ location: str,
439
+ lat: Optional[str] = None,
440
+ lng: Optional[str] = None,
441
+ fixed_coordinates: Optional[int] = None,
442
+ ) -> str:
443
+ """Add a new location with coordinates.
444
+
445
+ fixed_coordinates: 1=fixed, 0=auto-update from device.
446
+ """
447
+ data = {
448
+ "location": location, "lat": lat, "lng": lng,
449
+ "fixed_coordinates": fixed_coordinates,
450
+ }
451
+ return _json(client().add_location({k: v for k, v in data.items() if v is not None}))
452
+
453
+
454
+ @mcp.tool
455
+ def delete_location(location: str) -> str:
456
+ """Delete a location by name or ID."""
457
+ return _json(client().delete_location(location))
458
+
459
+
460
+ @mcp.tool
461
+ def edit_location(
462
+ location: str,
463
+ lat: Optional[str] = None,
464
+ lng: Optional[str] = None,
465
+ ) -> str:
466
+ """Edit a location's coordinates."""
467
+ data = {"lat": lat, "lng": lng}
468
+ return _json(client().edit_location(location, {k: v for k, v in data.items() if v is not None}))
469
+
470
+
471
+ # ── Sensors ───────────────────────────────────────────────────────────────────
472
+
473
+ @mcp.tool
474
+ def list_sensors() -> str:
475
+ """List all sensors discovered across all devices (temperature, humidity, voltage, etc.)."""
476
+ return _json(client().list_sensors())
477
+
478
+
479
+ # ── Device Groups ─────────────────────────────────────────────────────────────
480
+
481
+ @mcp.tool
482
+ def list_devicegroups() -> str:
483
+ """List all device groups."""
484
+ return _json(client().list_devicegroups())
485
+
486
+
487
+ @mcp.tool
488
+ def get_devicegroup(name: str) -> str:
489
+ """Get devices belonging to a specific device group."""
490
+ return _json(client().get_devicegroup(name))
491
+
492
+
493
+ # ── ARP ───────────────────────────────────────────────────────────────────────
494
+
495
+ @mcp.tool
496
+ def list_arp(query: str, device: Optional[str] = None) -> str:
497
+ """Look up ARP table entries by IP address, MAC address or CIDR range.
498
+
499
+ Optional: device to filter by device hostname.
500
+ """
501
+ params = {"device": device} if device else {}
502
+ return _json(client().list_arp(query, **params))
503
+
504
+
505
+ # ── Services ──────────────────────────────────────────────────────────────────
506
+
507
+ @mcp.tool
508
+ def list_services(hostname: Optional[str] = None) -> str:
509
+ """List Nagios-compatible service checks. Optional: filter by device hostname."""
510
+ return _json(client().list_services(hostname))
511
+
512
+
513
+ # ── Inventory ─────────────────────────────────────────────────────────────────
514
+
515
+ @mcp.tool
516
+ def get_inventory(hostname: str, entPhysicalClass: Optional[str] = None) -> str:
517
+ """Get hardware inventory (modules, cards, chassis) for a device.
518
+
519
+ Optional: entPhysicalClass to filter by physical class.
520
+ """
521
+ params = {"entPhysicalClass": entPhysicalClass} if entPhysicalClass else {}
522
+ return _json(client().get_inventory(hostname, **params))
523
+
524
+
525
+ def _spawn_daemon(args: argparse.Namespace) -> None:
526
+ """Re-launch this server as a detached background process."""
527
+ cmd = [sys.executable, "-m", "mcp_librenms",
528
+ "--config", str(_resolve_config(args.config)),
529
+ "--host", args.host, "--port", str(args.port)]
530
+ kwargs = dict(
531
+ stdout=subprocess.DEVNULL,
532
+ stderr=subprocess.DEVNULL,
533
+ stdin=subprocess.DEVNULL,
534
+ cwd=str(Path(__file__).resolve().parent.parent),
535
+ )
536
+ if os.name == "nt":
537
+ # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
538
+ kwargs["creationflags"] = 0x00000008 | 0x00000200
539
+ else:
540
+ kwargs["start_new_session"] = True
541
+ proc = subprocess.Popen(cmd, **kwargs)
542
+ print(f"mcp-librenms started in background (PID {proc.pid})")
543
+ print(f"Endpoint: http://{args.host}:{args.port}/mcp")
544
+
545
+
546
+ def run():
547
+ """Console-script entry point.
548
+
549
+ Default: HTTP transport on 0.0.0.0:5757 (endpoint http://<ip>:5757/mcp).
550
+ Options: --config (YAML config), --daemon (background), --host, --port, --stdio.
551
+ """
552
+ parser = argparse.ArgumentParser(description="LibreNMS MCP server (FastMCP)")
553
+ parser.add_argument("--config", default="config.yaml",
554
+ help="path to YAML config file (default: config.yaml)")
555
+ parser.add_argument("--daemon", action="store_true",
556
+ help="run in background (detached process)")
557
+ parser.add_argument("--host", default="0.0.0.0",
558
+ help="bind address (default: 0.0.0.0)")
559
+ parser.add_argument("--port", type=int, default=5757,
560
+ help="bind port (default: 5757)")
561
+ parser.add_argument("--stdio", action="store_true",
562
+ help="use stdio transport instead of HTTP")
563
+ args = parser.parse_args()
564
+
565
+ global _config
566
+ try:
567
+ _config = load_config(args.config)
568
+ except (FileNotFoundError, ValueError) as e:
569
+ parser.error(str(e))
570
+
571
+ if args.daemon:
572
+ _spawn_daemon(args)
573
+ return
574
+
575
+ if args.stdio:
576
+ mcp.run(transport="stdio")
577
+ else:
578
+ mcp.run(transport="http", host=args.host, port=args.port)
579
+
580
+
581
+ if __name__ == "__main__":
582
+ run()
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-librenms
3
+ Version: 0.1.0
4
+ Summary: MCP server that exposes the LibreNMS REST API as MCP tools
5
+ License: MIT
6
+ Keywords: mcp,librenms,monitoring,snmp,network
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Topic :: System :: Monitoring
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: fastmcp>=2.0.0
12
+ Requires-Dist: httpx>=0.27.0
13
+ Requires-Dist: pyyaml>=6.0
14
+
15
+ # mcp-librenms
16
+
17
+ MCP (Model Context Protocol) server that exposes the [LibreNMS](https://www.librenms.org/) REST API as MCP tools. Built with [FastMCP](https://github.com/jlowin/fastmcp). Connect AI assistants (Claude Desktop, GitHub Copilot, etc.) to your LibreNMS instance to query and manage monitored devices, ports, alerts, logs, and more.
18
+
19
+ ## Features
20
+
21
+ ~40 tools covering:
22
+
23
+ | Category | Tools |
24
+ |---|---|
25
+ | **System** | `ping`, `system_info` |
26
+ | **Devices** | `list_devices`, `get_device`, `add_device`, `delete_device`, `update_device_field`, `rename_device`, `discover_device`, `get_device_ports`, `get_device_ip_addresses`, `get_device_availability`, `get_device_outages`, `get_device_groups`, `get_device_components`, `get_device_graphs`, `get_device_maintenance`, `set_device_maintenance`, `add_device_eventlog` |
27
+ | **Alerts** | `list_alerts`, `get_alert`, `ack_alert`, `unmute_alert`, `list_alert_rules`, `get_alert_rule`, `delete_alert_rule` |
28
+ | **Ports** | `get_all_ports`, `search_ports`, `get_port_info`, `get_port_ip_info`, `ports_with_mac`, `update_port_description` |
29
+ | **Logs** | `list_eventlog`, `list_syslog`, `list_alertlog`, `list_authlog` |
30
+ | **Locations** | `list_locations`, `get_location`, `add_location`, `delete_location`, `edit_location` |
31
+ | **Sensors** | `list_sensors` |
32
+ | **Device groups** | `list_devicegroups`, `get_devicegroup` |
33
+ | **ARP** | `list_arp` |
34
+ | **Services** | `list_services` |
35
+ | **Inventory** | `get_inventory` |
36
+
37
+ ## Requirements
38
+
39
+ - Python 3.9+
40
+ - A LibreNMS instance with API access
41
+ - An API token (LibreNMS web UI: **Settings > API > API Settings**)
42
+
43
+ ## Setup
44
+
45
+ ```bash
46
+ # 1. Create and activate a virtual environment
47
+ python -m venv .venv
48
+ .venv\Scripts\activate # Windows
49
+ source .venv/bin/activate # Linux/macOS
50
+
51
+ # 2. Install the package (editable)
52
+ pip install -e .
53
+
54
+ # 3. Configure
55
+ copy config.example.yaml config.yaml # Windows
56
+ # cp config.example.yaml config.yaml # Linux/macOS
57
+ # then edit config.yaml with your LibreNMS URL and API token
58
+ ```
59
+
60
+ ### Configuration
61
+
62
+ `config.yaml` (gitignored — never commit it):
63
+
64
+ ```yaml
65
+ librenms:
66
+ url: "http://your-librenms-host" # base URL, no trailing slash
67
+ token: "your-api-token" # LibreNMS web UI: Settings > API > API Settings
68
+ ```
69
+
70
+ ## Running
71
+
72
+ The server speaks MCP over **streamable HTTP** by default, bound to `0.0.0.0:5757`:
73
+
74
+ ```bash
75
+ # Console script (foreground)
76
+ mcp-librenms --config config.yaml
77
+
78
+ # Or as a module
79
+ python -m mcp_librenms --config config.yaml
80
+ ```
81
+
82
+ Endpoint: **http://\<IP\>:5757/mcp**
83
+
84
+ ### Options
85
+
86
+ | Flag | Default | Description |
87
+ |---|---|---|
88
+ | `--config FILE` | `config.yaml` | Path to YAML config file (CWD, then project root) |
89
+ | `--host IP` | `0.0.0.0` | Bind address |
90
+ | `--port PORT` | `5757` | Bind port |
91
+ | `--daemon` | off | Run in background (detached process) |
92
+ | `--stdio` | off | Use stdio transport instead of HTTP |
93
+
94
+ Examples:
95
+
96
+ ```bash
97
+ # Background on a custom address/port
98
+ mcp-librenms --config config.yaml --daemon --host 192.168.1.10 --port 8080
99
+ # → http://192.168.1.10:8080/mcp
100
+
101
+ # stdio transport (for MCP clients that spawn the server)
102
+ mcp-librenms --config config.yaml --stdio
103
+ ```
104
+
105
+ ### MCP client configuration
106
+
107
+ Example for any MCP client that supports streamable HTTP servers:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "librenms": {
113
+ "url": "http://your-librenms-host:5757/mcp"
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ For stdio-based clients (Claude Desktop, etc.), spawn the server with `--stdio` and point `--config` at your config file:
120
+
121
+ ```json
122
+ {
123
+ "mcpServers": {
124
+ "librenms": {
125
+ "command": "D:\\path\\to\\.venv\\Scripts\\mcp-librenms.exe",
126
+ "args": ["--config", "D:\\path\\to\\mcp-librenms\\config.yaml", "--stdio"]
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ > **Note:** the client uses `verify=False` for TLS, so it works with LibreNMS instances behind self-signed certificates.
133
+
134
+ ## Project structure
135
+
136
+ ```
137
+ mcp-librenms/
138
+ ├── config.example.yaml # Template for local configuration
139
+ ├── config.yaml # Your local configuration (gitignored)
140
+ ├── .gitignore
141
+ ├── README.md
142
+ ├── pyproject.toml # Package metadata + console script
143
+ ├── requirements.txt # Flat dependency list
144
+ └── mcp_librenms/
145
+ ├── __init__.py
146
+ ├── __main__.py # python -m mcp_librenms
147
+ ├── client.py # LibreNMS REST API HTTP client
148
+ └── server.py # MCP server + tool definitions
149
+ ```
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ mcp_librenms/__init__.py
4
+ mcp_librenms/__main__.py
5
+ mcp_librenms/client.py
6
+ mcp_librenms/server.py
7
+ mcp_librenms.egg-info/PKG-INFO
8
+ mcp_librenms.egg-info/SOURCES.txt
9
+ mcp_librenms.egg-info/dependency_links.txt
10
+ mcp_librenms.egg-info/entry_points.txt
11
+ mcp_librenms.egg-info/requires.txt
12
+ mcp_librenms.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-librenms = mcp_librenms.server:run
@@ -0,0 +1,3 @@
1
+ fastmcp>=2.0.0
2
+ httpx>=0.27.0
3
+ pyyaml>=6.0
@@ -0,0 +1 @@
1
+ mcp_librenms
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mcp-librenms"
7
+ version = "0.1.0"
8
+ description = "MCP server that exposes the LibreNMS REST API as MCP tools"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["mcp", "librenms", "monitoring", "snmp", "network"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: System :: Monitoring",
16
+ ]
17
+ dependencies = [
18
+ "fastmcp>=2.0.0",
19
+ "httpx>=0.27.0",
20
+ "pyyaml>=6.0",
21
+ ]
22
+
23
+ [project.scripts]
24
+ mcp-librenms = "mcp_librenms.server:run"
25
+
26
+ [tool.setuptools]
27
+ packages = ["mcp_librenms"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+