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.
- mcp_librenms-0.1.0/PKG-INFO +153 -0
- mcp_librenms-0.1.0/README.md +139 -0
- mcp_librenms-0.1.0/mcp_librenms/__init__.py +3 -0
- mcp_librenms-0.1.0/mcp_librenms/__main__.py +6 -0
- mcp_librenms-0.1.0/mcp_librenms/client.py +211 -0
- mcp_librenms-0.1.0/mcp_librenms/server.py +582 -0
- mcp_librenms-0.1.0/mcp_librenms.egg-info/PKG-INFO +153 -0
- mcp_librenms-0.1.0/mcp_librenms.egg-info/SOURCES.txt +12 -0
- mcp_librenms-0.1.0/mcp_librenms.egg-info/dependency_links.txt +1 -0
- mcp_librenms-0.1.0/mcp_librenms.egg-info/entry_points.txt +2 -0
- mcp_librenms-0.1.0/mcp_librenms.egg-info/requires.txt +3 -0
- mcp_librenms-0.1.0/mcp_librenms.egg-info/top_level.txt +1 -0
- mcp_librenms-0.1.0/pyproject.toml +27 -0
- mcp_librenms-0.1.0/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|