zapi-mcp 0.2.0__py3-none-any.whl
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.
- zapi_mcp/__init__.py +3 -0
- zapi_mcp/__main__.py +68 -0
- zapi_mcp/categories.py +82 -0
- zapi_mcp/client.py +286 -0
- zapi_mcp/server.py +502 -0
- zapi_mcp-0.2.0.dist-info/METADATA +242 -0
- zapi_mcp-0.2.0.dist-info/RECORD +10 -0
- zapi_mcp-0.2.0.dist-info/WHEEL +5 -0
- zapi_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- zapi_mcp-0.2.0.dist-info/top_level.txt +1 -0
zapi_mcp/__init__.py
ADDED
zapi_mcp/__main__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Entry point for zapi-mcp."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from zapi_mcp import __version__
|
|
8
|
+
from zapi_mcp.client import ZapiClient, ZapiError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="Zabbix API MCP Server",
|
|
14
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
15
|
+
epilog="""
|
|
16
|
+
Required environment variables:
|
|
17
|
+
ZABBIX_URL Zabbix frontend URL (e.g. https://zabbix.example.com)
|
|
18
|
+
ZABBIX_USER Zabbix API user
|
|
19
|
+
ZABBIX_PASSWORD Zabbix API password
|
|
20
|
+
ZABBIX_CATEGORIES_INI Path to categories INI for --brief (optional)
|
|
21
|
+
""",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--version", action="store_true", help="Print version and exit")
|
|
24
|
+
parser.add_argument("--check", action="store_true", help="Verify connection and exit")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--brief",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Print the daily_brief to stdout and exit (handy for cron / smoke tests)",
|
|
29
|
+
)
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
if args.version:
|
|
33
|
+
print(f"zapi-mcp {__version__}")
|
|
34
|
+
sys.exit(0)
|
|
35
|
+
|
|
36
|
+
url = os.environ.get("ZABBIX_URL")
|
|
37
|
+
user = os.environ.get("ZABBIX_USER")
|
|
38
|
+
password = os.environ.get("ZABBIX_PASSWORD")
|
|
39
|
+
|
|
40
|
+
if not all([url, user, password]):
|
|
41
|
+
pairs = [("ZABBIX_URL", url), ("ZABBIX_USER", user), ("ZABBIX_PASSWORD", password)]
|
|
42
|
+
missing = [v for v, k in pairs if not k]
|
|
43
|
+
print(f"Error: missing environment variables: {', '.join(missing)}", file=sys.stderr)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
if args.check:
|
|
47
|
+
try:
|
|
48
|
+
client = ZapiClient(url, user, password)
|
|
49
|
+
auth = "Bearer header" if client._bearer else "auth field"
|
|
50
|
+
print(f"OK — Zabbix API {client.version} (auth: {auth})")
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
except ZapiError as e:
|
|
53
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
54
|
+
sys.exit(2)
|
|
55
|
+
|
|
56
|
+
if args.brief:
|
|
57
|
+
from zapi_mcp.server import daily_brief
|
|
58
|
+
|
|
59
|
+
print(daily_brief())
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
from zapi_mcp.server import mcp
|
|
63
|
+
|
|
64
|
+
mcp.run()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
zapi_mcp/categories.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Site-specific monitoring categories for ``daily_brief``.
|
|
2
|
+
|
|
3
|
+
Categories are loaded from an INI file pointed to by ``ZABBIX_CATEGORIES_INI``.
|
|
4
|
+
This keeps organization-specific Zabbix tags and item keys out of the codebase
|
|
5
|
+
so the server stays generic and publishable. When unset or missing,
|
|
6
|
+
``daily_brief`` falls back to a generic active-problems summary.
|
|
7
|
+
|
|
8
|
+
Each ``[section]`` defines one category::
|
|
9
|
+
|
|
10
|
+
[dhcp]
|
|
11
|
+
name = DHCP Pool Usage
|
|
12
|
+
tag = dhcp-pool-usage ; Zabbix host tag identifying the group
|
|
13
|
+
item_key = usage ; if set -> report current item values
|
|
14
|
+
threshold = 80 ; optional; flag values >= this
|
|
15
|
+
|
|
16
|
+
[core]
|
|
17
|
+
name = Core Network
|
|
18
|
+
tag = role
|
|
19
|
+
tag_value = main ; tag must equal this value
|
|
20
|
+
; no item_key -> report active problems
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import configparser
|
|
24
|
+
import os
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_float(value: str | None) -> float | None:
|
|
29
|
+
try:
|
|
30
|
+
return float(value) if value else None
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Category:
|
|
37
|
+
"""One monitoring category for the daily brief."""
|
|
38
|
+
|
|
39
|
+
key: str
|
|
40
|
+
name: str
|
|
41
|
+
tag: str
|
|
42
|
+
tag_value: str | None = None
|
|
43
|
+
item_key: str | None = None
|
|
44
|
+
item_key_search: str | None = None
|
|
45
|
+
threshold: float | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def kind(self) -> str:
|
|
49
|
+
"""``items`` when an item key (exact or substring) is configured, else ``problems``."""
|
|
50
|
+
return "items" if (self.item_key or self.item_key_search) else "problems"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_categories(path: str | None = None) -> list[Category]:
|
|
54
|
+
"""Load categories from an INI file (env ``ZABBIX_CATEGORIES_INI`` by default).
|
|
55
|
+
|
|
56
|
+
Returns an empty list when no path is configured or the file is absent.
|
|
57
|
+
"""
|
|
58
|
+
path = path or os.environ.get("ZABBIX_CATEGORIES_INI")
|
|
59
|
+
if not path or not os.path.isfile(path):
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
cp = configparser.ConfigParser()
|
|
63
|
+
cp.read(path)
|
|
64
|
+
|
|
65
|
+
categories: list[Category] = []
|
|
66
|
+
for section in cp.sections():
|
|
67
|
+
s = cp[section]
|
|
68
|
+
tag = s.get("tag")
|
|
69
|
+
if not tag:
|
|
70
|
+
continue
|
|
71
|
+
categories.append(
|
|
72
|
+
Category(
|
|
73
|
+
key=section,
|
|
74
|
+
name=s.get("name", section),
|
|
75
|
+
tag=tag,
|
|
76
|
+
tag_value=s.get("tag_value") or None,
|
|
77
|
+
item_key=s.get("item_key") or None,
|
|
78
|
+
item_key_search=s.get("item_key_search") or None,
|
|
79
|
+
threshold=_safe_float(s.get("threshold")),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return categories
|
zapi_mcp/client.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Zabbix JSON-RPC API client.
|
|
2
|
+
|
|
3
|
+
All requests target a single ``/api_jsonrpc.php`` endpoint; the called method is
|
|
4
|
+
carried in the request body. Authentication is version-adaptive but always
|
|
5
|
+
degrades to the proven ``user`` + ``auth``-field path used by older Zabbix
|
|
6
|
+
(<= 6.2), so the client works against current production while staying
|
|
7
|
+
forward-compatible with 6.4 / 7.0 (``username`` + ``Authorization: Bearer``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
DEFAULT_TIMEOUT = 30
|
|
13
|
+
|
|
14
|
+
# Zabbix tag-filter operators (host.get / problem.get / event.get)
|
|
15
|
+
TAG_OP_EQUAL = "1"
|
|
16
|
+
TAG_OP_EXISTS = "4"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ZapiError(Exception):
|
|
20
|
+
"""Base error for Zabbix API failures."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ZapiAuthError(ZapiError):
|
|
24
|
+
"""Raised when authentication (user.login) fails."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tag_filter(tag: str, value: str | None = None) -> dict:
|
|
28
|
+
"""Build a Zabbix tag filter: Equal when a value is given, else Exists."""
|
|
29
|
+
if value:
|
|
30
|
+
return {"tag": tag, "value": value, "operator": TAG_OP_EQUAL}
|
|
31
|
+
return {"tag": tag, "operator": TAG_OP_EXISTS}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ZapiClient:
|
|
35
|
+
"""Minimal Zabbix API client using JSON-RPC over a single endpoint."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, url: str, user: str, password: str, *, timeout: int = DEFAULT_TIMEOUT):
|
|
38
|
+
base = url.rstrip("/")
|
|
39
|
+
if not base.endswith("/api_jsonrpc.php"):
|
|
40
|
+
base += "/api_jsonrpc.php"
|
|
41
|
+
self._url = base
|
|
42
|
+
self._http = httpx.Client(timeout=timeout, headers={"Content-Type": "application/json"})
|
|
43
|
+
self._token: str | None = None
|
|
44
|
+
self._bearer = False # use Authorization: Bearer header instead of `auth` field
|
|
45
|
+
self.version = self.api_version()
|
|
46
|
+
self._token = self._login(user, password)
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# Low-level call
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
def _call(self, method: str, params: dict, *, auth: bool = True) -> object:
|
|
52
|
+
data: dict = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
|
|
53
|
+
headers: dict = {}
|
|
54
|
+
if auth and self._token:
|
|
55
|
+
if self._bearer:
|
|
56
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
57
|
+
else:
|
|
58
|
+
data["auth"] = self._token
|
|
59
|
+
try:
|
|
60
|
+
resp = self._http.post(self._url, json=data, headers=headers or None)
|
|
61
|
+
resp.raise_for_status()
|
|
62
|
+
body = resp.json()
|
|
63
|
+
except httpx.HTTPStatusError as e:
|
|
64
|
+
raise ZapiError(f"HTTP {e.response.status_code}: {method}") from e
|
|
65
|
+
except httpx.HTTPError as e:
|
|
66
|
+
raise ZapiError(f"Connection error calling {method}: {e}") from e
|
|
67
|
+
if err := body.get("error"):
|
|
68
|
+
if method == "user.login":
|
|
69
|
+
raise ZapiAuthError(f"Authentication failed: {err}")
|
|
70
|
+
raise ZapiError(f"{method} failed: {err}")
|
|
71
|
+
return body["result"]
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Version detection & auth
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
def api_version(self) -> str:
|
|
77
|
+
return self._call("apiinfo.version", {}, auth=False)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _version_tuple(version: str) -> tuple[int, int]:
|
|
81
|
+
try:
|
|
82
|
+
parts = version.split(".")
|
|
83
|
+
return int(parts[0]), int(parts[1])
|
|
84
|
+
except (ValueError, IndexError):
|
|
85
|
+
return (0, 0)
|
|
86
|
+
|
|
87
|
+
def _login(self, user: str, password: str) -> str:
|
|
88
|
+
"""Log in, choosing the param name by version and degrading to proven `user`.
|
|
89
|
+
|
|
90
|
+
Zabbix 6.4 renamed the login parameter ``user`` -> ``username`` and added
|
|
91
|
+
Bearer-header auth. We pick by detected version, then fall back to the
|
|
92
|
+
other param name if the first attempt errors (so a misdetected version
|
|
93
|
+
still authenticates).
|
|
94
|
+
"""
|
|
95
|
+
modern = self._version_tuple(self.version) >= (6, 4)
|
|
96
|
+
self._bearer = modern
|
|
97
|
+
primary = "username" if modern else "user"
|
|
98
|
+
fallback = "user" if modern else "username"
|
|
99
|
+
try:
|
|
100
|
+
return self._call("user.login", {primary: user, "password": password}, auth=False)
|
|
101
|
+
except ZapiAuthError as e:
|
|
102
|
+
# A genuine credential failure must not trigger a second login
|
|
103
|
+
# attempt (avoid doubling lockout / audit pressure).
|
|
104
|
+
msg = str(e).lower()
|
|
105
|
+
if "incorrect" in msg or "password" in msg or "no permissions" in msg:
|
|
106
|
+
raise
|
|
107
|
+
# Otherwise the param name was likely wrong for this version: retry
|
|
108
|
+
# with the other name and degrade to the proven `auth` field.
|
|
109
|
+
self._bearer = False
|
|
110
|
+
return self._call("user.login", {fallback: user, "password": password}, auth=False)
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Lifecycle
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
self._http.close()
|
|
117
|
+
|
|
118
|
+
def __enter__(self) -> "ZapiClient":
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *exc) -> None:
|
|
122
|
+
self.close()
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Host groups
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
def _get_group_ids(self, group: str) -> list[str]:
|
|
128
|
+
result = self._call("hostgroup.get", {"output": "groupid", "filter": {"name": [group]}})
|
|
129
|
+
return [r["groupid"] for r in result]
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Hosts
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
def get_hosts(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
tags: list[dict] | None = None,
|
|
138
|
+
group: str | None = None,
|
|
139
|
+
host: str | None = None,
|
|
140
|
+
) -> list[dict]:
|
|
141
|
+
"""Return hosts, optionally filtered by tags, group name, or exact host."""
|
|
142
|
+
params: dict = {
|
|
143
|
+
"output": ["hostid", "host", "name", "status"],
|
|
144
|
+
"selectTags": "extend",
|
|
145
|
+
"selectInterfaces": ["ip"],
|
|
146
|
+
}
|
|
147
|
+
if tags:
|
|
148
|
+
params["tags"] = tags
|
|
149
|
+
if group:
|
|
150
|
+
params["groupids"] = self._get_group_ids(group)
|
|
151
|
+
if host:
|
|
152
|
+
params["filter"] = {"host": host}
|
|
153
|
+
return self._call("host.get", params)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Items (current values)
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
def get_items(
|
|
159
|
+
self,
|
|
160
|
+
host_ids: list[str],
|
|
161
|
+
*,
|
|
162
|
+
key: str | None = None,
|
|
163
|
+
key_search: str | None = None,
|
|
164
|
+
name_search: str | None = None,
|
|
165
|
+
) -> list[dict]:
|
|
166
|
+
"""Return items with last value for given hosts.
|
|
167
|
+
|
|
168
|
+
``key`` filters by exact item key (key_); ``key_search`` does a substring
|
|
169
|
+
match on the key (e.g. ".usage" to catch ``pool.node0.usage``);
|
|
170
|
+
``name_search`` does a substring match on the item name.
|
|
171
|
+
"""
|
|
172
|
+
params: dict = {
|
|
173
|
+
"output": ["itemid", "hostid", "name", "key_", "lastvalue", "units", "lastclock"],
|
|
174
|
+
"hostids": host_ids,
|
|
175
|
+
"selectTags": "extend",
|
|
176
|
+
}
|
|
177
|
+
if key:
|
|
178
|
+
params["filter"] = {"key_": key}
|
|
179
|
+
search = {}
|
|
180
|
+
if key_search:
|
|
181
|
+
search["key_"] = key_search
|
|
182
|
+
if name_search:
|
|
183
|
+
search["name"] = name_search
|
|
184
|
+
if search:
|
|
185
|
+
params["search"] = search
|
|
186
|
+
return self._call("item.get", params)
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Problems
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
def get_problems(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
severities: list[int] | None = None,
|
|
195
|
+
tags: list[dict] | None = None,
|
|
196
|
+
limit: int = 100,
|
|
197
|
+
) -> list[dict]:
|
|
198
|
+
"""Return active problems, optionally filtered by severity and tags.
|
|
199
|
+
|
|
200
|
+
Output includes ``eventid`` so callers can acknowledge problems.
|
|
201
|
+
"""
|
|
202
|
+
params: dict = {
|
|
203
|
+
"output": "extend",
|
|
204
|
+
"selectAcknowledges": "count",
|
|
205
|
+
"selectTags": "extend",
|
|
206
|
+
# problem.get only permits "eventid" as a sortfield; callers that
|
|
207
|
+
# need severity ordering re-bucket in Python.
|
|
208
|
+
"sortfield": "eventid",
|
|
209
|
+
"sortorder": "DESC",
|
|
210
|
+
"limit": limit,
|
|
211
|
+
"suppressed": False,
|
|
212
|
+
}
|
|
213
|
+
if severities:
|
|
214
|
+
params["severities"] = severities
|
|
215
|
+
if tags:
|
|
216
|
+
params["tags"] = tags
|
|
217
|
+
return self._call("problem.get", params)
|
|
218
|
+
|
|
219
|
+
def count_problems(
|
|
220
|
+
self,
|
|
221
|
+
*,
|
|
222
|
+
severities: list[int] | None = None,
|
|
223
|
+
tags: list[dict] | None = None,
|
|
224
|
+
) -> int:
|
|
225
|
+
"""Return the total count of active problems matching the filters.
|
|
226
|
+
|
|
227
|
+
Uses Zabbix ``countOutput`` so callers can report an accurate total even
|
|
228
|
+
when ``get_problems`` is capped by ``limit`` (avoids silent truncation).
|
|
229
|
+
"""
|
|
230
|
+
params: dict = {"countOutput": True, "suppressed": False}
|
|
231
|
+
if severities:
|
|
232
|
+
params["severities"] = severities
|
|
233
|
+
if tags:
|
|
234
|
+
params["tags"] = tags
|
|
235
|
+
result = self._call("problem.get", params)
|
|
236
|
+
try:
|
|
237
|
+
return int(result) # countOutput returns the count as a numeric string
|
|
238
|
+
except (TypeError, ValueError) as e:
|
|
239
|
+
# A genuine API failure already raised in _call; an unexpected shape
|
|
240
|
+
# here is a contract violation worth surfacing, not masking as 0.
|
|
241
|
+
raise ZapiError(f"problem.get countOutput returned non-numeric: {result!r}") from e
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
# Events
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
def get_events(
|
|
247
|
+
self,
|
|
248
|
+
*,
|
|
249
|
+
time_from: int | None = None,
|
|
250
|
+
severities: list[int] | None = None,
|
|
251
|
+
limit: int = 100,
|
|
252
|
+
) -> list[dict]:
|
|
253
|
+
"""Return recent problem events (source=trigger, value=problem)."""
|
|
254
|
+
params: dict = {
|
|
255
|
+
"output": "extend",
|
|
256
|
+
"selectTags": "extend",
|
|
257
|
+
"selectHosts": ["host", "name"],
|
|
258
|
+
"source": 0,
|
|
259
|
+
"object": 0,
|
|
260
|
+
"value": 1,
|
|
261
|
+
"sortfield": ["clock", "eventid"],
|
|
262
|
+
"sortorder": "DESC",
|
|
263
|
+
"limit": limit,
|
|
264
|
+
}
|
|
265
|
+
if time_from:
|
|
266
|
+
params["time_from"] = time_from
|
|
267
|
+
if severities:
|
|
268
|
+
params["severities"] = severities
|
|
269
|
+
return self._call("event.get", params)
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
# Acknowledge
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
def acknowledge_problem(self, event_ids: list[str], message: str = "") -> dict:
|
|
275
|
+
"""Acknowledge problems, optionally adding a message.
|
|
276
|
+
|
|
277
|
+
Action is a bitmask: acknowledge (2), plus add-message (4) only when a
|
|
278
|
+
non-empty message is given (Zabbix rejects an empty message when bit 4
|
|
279
|
+
is set). Does NOT close problems (close is bit 1), so the tool is safe
|
|
280
|
+
even when triggers disallow manual close.
|
|
281
|
+
"""
|
|
282
|
+
action = 2 | (4 if message else 0)
|
|
283
|
+
params: dict = {"eventids": event_ids, "action": action}
|
|
284
|
+
if message:
|
|
285
|
+
params["message"] = message
|
|
286
|
+
return self._call("event.acknowledge", params)
|
zapi_mcp/server.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Zabbix API MCP Server — tools."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from zapi_mcp.categories import Category, load_categories
|
|
9
|
+
from zapi_mcp.client import ZapiClient, ZapiError, tag_filter
|
|
10
|
+
|
|
11
|
+
mcp = FastMCP("zapi-mcp")
|
|
12
|
+
|
|
13
|
+
_SEVERITY = {
|
|
14
|
+
0: "Not classified",
|
|
15
|
+
1: "Information",
|
|
16
|
+
2: "Warning",
|
|
17
|
+
3: "Average",
|
|
18
|
+
4: "High",
|
|
19
|
+
5: "Disaster",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Zabbix severity levels (Warning and above = the ones worth a morning glance)
|
|
23
|
+
SEVERITY_WARNING_AND_ABOVE = [2, 3, 4, 5]
|
|
24
|
+
MAX_SEVERITY = 5
|
|
25
|
+
|
|
26
|
+
# How many item rows to show per brief category (plus any over threshold)
|
|
27
|
+
BRIEF_ITEM_LIMIT = 12
|
|
28
|
+
|
|
29
|
+
# How many active problems to fetch for the brief by default. Large enough to
|
|
30
|
+
# cover a real warning+ backlog in one call; if the cap is hit we query an
|
|
31
|
+
# accurate total separately so the count is never silently truncated. Tunable
|
|
32
|
+
# via ZABBIX_BRIEF_PROBLEM_LIMIT.
|
|
33
|
+
DEFAULT_BRIEF_PROBLEM_LIMIT = 1000
|
|
34
|
+
|
|
35
|
+
# Default "recent" window: problems newer than this are listed in full, older
|
|
36
|
+
# ("stale") ones are folded to a count. Tunable via ZABBIX_BRIEF_RECENT_HOURS so
|
|
37
|
+
# a morning patrol can focus on what just happened, not year-old fossils that
|
|
38
|
+
# Zabbix keeps active because their recovery is never auto-confirmed.
|
|
39
|
+
DEFAULT_RECENT_HOURS = 24
|
|
40
|
+
|
|
41
|
+
# Cached client: a stdio server is long-lived and single-user, so we build and
|
|
42
|
+
# authenticate once, reusing the httpx pool and Zabbix session across calls.
|
|
43
|
+
_CLIENT: ZapiClient | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _client() -> ZapiClient:
|
|
47
|
+
global _CLIENT
|
|
48
|
+
if _CLIENT is None:
|
|
49
|
+
_CLIENT = ZapiClient(
|
|
50
|
+
os.environ["ZABBIX_URL"],
|
|
51
|
+
os.environ["ZABBIX_USER"],
|
|
52
|
+
os.environ["ZABBIX_PASSWORD"],
|
|
53
|
+
)
|
|
54
|
+
return _CLIENT
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def reset_client() -> None:
|
|
58
|
+
"""Drop the cached client so the next call re-authenticates (token refresh)."""
|
|
59
|
+
global _CLIENT
|
|
60
|
+
if _CLIENT is not None:
|
|
61
|
+
try:
|
|
62
|
+
_CLIENT.close()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
_CLIENT = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fmt_time(epoch: int | str | None) -> str:
|
|
69
|
+
try:
|
|
70
|
+
e = int(epoch)
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
return "—"
|
|
73
|
+
if e == 0:
|
|
74
|
+
return "—"
|
|
75
|
+
try:
|
|
76
|
+
return datetime.fromtimestamp(e).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
77
|
+
except (OverflowError, OSError):
|
|
78
|
+
return str(epoch)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _severity_name(sev: int | str | None) -> str:
|
|
82
|
+
try:
|
|
83
|
+
return _SEVERITY.get(int(sev), str(sev))
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return str(sev)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _fmt_tags(tags: list[dict]) -> str:
|
|
89
|
+
return ", ".join(f"{t['tag']}={t['value']}" if t.get("value") else t["tag"] for t in tags)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_acked(problem: dict) -> bool:
|
|
93
|
+
"""True when the problem's `acknowledged` boolean field is set."""
|
|
94
|
+
return str(problem.get("acknowledged", "0")) == "1"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _item_value(item: dict) -> float:
|
|
98
|
+
try:
|
|
99
|
+
return float(item.get("lastvalue") or 0)
|
|
100
|
+
except (ValueError, TypeError):
|
|
101
|
+
return 0.0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _fmt_value(item: dict) -> str:
|
|
105
|
+
"""Round numeric values to 1 decimal; pass non-numeric through; — for empty."""
|
|
106
|
+
raw = item.get("lastvalue")
|
|
107
|
+
if raw in (None, ""):
|
|
108
|
+
return "—"
|
|
109
|
+
try:
|
|
110
|
+
return f"{float(raw):.1f}"
|
|
111
|
+
except (ValueError, TypeError):
|
|
112
|
+
return str(raw)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _clock(problem: dict) -> int:
|
|
116
|
+
"""Problem onset epoch as int (0 when missing or unparseable)."""
|
|
117
|
+
try:
|
|
118
|
+
return int(problem.get("clock") or 0)
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _fmt_age(epoch: int, now_ts: int) -> str:
|
|
124
|
+
"""Coarse human age from `epoch` to `now_ts`, e.g. '<1m ago', '5m ago', '3h ago', '47d ago'."""
|
|
125
|
+
if epoch <= 0:
|
|
126
|
+
return "?"
|
|
127
|
+
secs = max(0, now_ts - epoch)
|
|
128
|
+
if secs < 60:
|
|
129
|
+
return "<1m ago"
|
|
130
|
+
if secs < 3600:
|
|
131
|
+
return f"{secs // 60}m ago"
|
|
132
|
+
if secs < 86400:
|
|
133
|
+
return f"{secs // 3600}h ago"
|
|
134
|
+
return f"{secs // 86400}d ago"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _recent_window_seconds() -> int:
|
|
138
|
+
"""Seconds of the 'recent' window (env ZABBIX_BRIEF_RECENT_HOURS, default 24h)."""
|
|
139
|
+
try:
|
|
140
|
+
hours = float(os.environ.get("ZABBIX_BRIEF_RECENT_HOURS", DEFAULT_RECENT_HOURS))
|
|
141
|
+
except (TypeError, ValueError):
|
|
142
|
+
hours = float(DEFAULT_RECENT_HOURS)
|
|
143
|
+
return int(max(0.0, hours) * 3600)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _brief_problem_limit() -> int:
|
|
147
|
+
"""Problem fetch cap for the brief (env ZABBIX_BRIEF_PROBLEM_LIMIT, default 1000).
|
|
148
|
+
|
|
149
|
+
Parsed via float() then int() so a stray decimal (e.g. "10.5") truncates to a
|
|
150
|
+
usable value rather than silently reverting to the default, matching how
|
|
151
|
+
_recent_window_seconds tolerates fractional input.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
limit = int(float(os.environ.get("ZABBIX_BRIEF_PROBLEM_LIMIT", DEFAULT_BRIEF_PROBLEM_LIMIT)))
|
|
155
|
+
except (TypeError, ValueError):
|
|
156
|
+
limit = DEFAULT_BRIEF_PROBLEM_LIMIT
|
|
157
|
+
return max(1, limit)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _window_label(seconds: int) -> str:
|
|
161
|
+
"""Human label for the recent window, e.g. '24h', '90m', '30s'.
|
|
162
|
+
|
|
163
|
+
Rounds to whole minutes above a minute so a fractional ZABBIX_BRIEF_RECENT_HOURS
|
|
164
|
+
(e.g. 1.001 -> 3603s) still reads as a clean duration rather than raw seconds.
|
|
165
|
+
"""
|
|
166
|
+
if seconds < 60:
|
|
167
|
+
return f"{seconds}s"
|
|
168
|
+
minutes = round(seconds / 60)
|
|
169
|
+
if minutes % 60 == 0:
|
|
170
|
+
return f"{minutes // 60}h"
|
|
171
|
+
return f"{minutes}m"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _count_fragment(shown: int, total: int) -> str:
|
|
175
|
+
"""'5' when the listing is complete, 'showing 5 of 20' when capped."""
|
|
176
|
+
return f"showing {shown} of {total}" if total > shown else f"{total}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _problem_line(problem: dict, now_ts: int, *, with_severity: bool = False) -> str:
|
|
180
|
+
"""One problem row: name, optional severity, eventid, onset time and age."""
|
|
181
|
+
ack = " [ack]" if _is_acked(problem) else ""
|
|
182
|
+
sev = f"[{_severity_name(problem['severity'])}] " if with_severity else ""
|
|
183
|
+
clock = _clock(problem)
|
|
184
|
+
return (
|
|
185
|
+
f"- {sev}{problem['name']}{ack} "
|
|
186
|
+
f"eventid={problem.get('eventid', '?')} ({_fmt_time(clock)}, {_fmt_age(clock, now_ts)})"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _emit_problem_bucket(
|
|
191
|
+
lines: list[str],
|
|
192
|
+
problems: list[dict],
|
|
193
|
+
now_ts: int,
|
|
194
|
+
recent_window: int,
|
|
195
|
+
*,
|
|
196
|
+
with_severity: bool = False,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Append problem rows newest-first: recent ones in full, stale ones folded to a count."""
|
|
199
|
+
ordered = sorted(problems, key=_clock, reverse=True)
|
|
200
|
+
recent = [p for p in ordered if now_ts - _clock(p) <= recent_window]
|
|
201
|
+
stale = [p for p in ordered if now_ts - _clock(p) > recent_window]
|
|
202
|
+
for p in recent:
|
|
203
|
+
lines.append(_problem_line(p, now_ts, with_severity=with_severity))
|
|
204
|
+
if stale:
|
|
205
|
+
oldest = min(_clock(p) for p in stale)
|
|
206
|
+
lines.append(f"- … and {len(stale)} older (stale; oldest {_fmt_time(oldest)})")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _fetch_problems_with_total(
|
|
210
|
+
client: ZapiClient,
|
|
211
|
+
*,
|
|
212
|
+
severities: list[int] | None = None,
|
|
213
|
+
tags: list[dict] | None = None,
|
|
214
|
+
limit: int | None = None,
|
|
215
|
+
) -> tuple[list[dict], int]:
|
|
216
|
+
"""Fetch problems (capped at `limit`) plus the accurate total.
|
|
217
|
+
|
|
218
|
+
`limit` defaults to the brief's configurable cap (ZABBIX_BRIEF_PROBLEM_LIMIT);
|
|
219
|
+
callers that honour a user-supplied limit pass it explicitly. When the fetch
|
|
220
|
+
hits the cap, the total is queried via countOutput so callers can report
|
|
221
|
+
'showing N of TOTAL' rather than silently truncating. If only the (secondary)
|
|
222
|
+
count query fails, we keep the fetched rows and fall back to their count
|
|
223
|
+
rather than discarding everything.
|
|
224
|
+
"""
|
|
225
|
+
if limit is None:
|
|
226
|
+
limit = _brief_problem_limit()
|
|
227
|
+
problems = client.get_problems(severities=severities, tags=tags, limit=limit)
|
|
228
|
+
if len(problems) < limit:
|
|
229
|
+
return problems, len(problems)
|
|
230
|
+
try:
|
|
231
|
+
total = client.count_problems(severities=severities, tags=tags)
|
|
232
|
+
except ZapiError:
|
|
233
|
+
total = len(problems)
|
|
234
|
+
return problems, max(total, len(problems))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# daily_brief — category-driven morning patrol
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
def _brief_item_category(client: ZapiClient, cat: Category) -> list[str]:
|
|
241
|
+
hosts = client.get_hosts(tags=[tag_filter(cat.tag, cat.tag_value)])
|
|
242
|
+
lines = [f"\n## {cat.name} ({len(hosts)} hosts)"]
|
|
243
|
+
if not hosts:
|
|
244
|
+
lines.append("No hosts found for this category.")
|
|
245
|
+
return lines
|
|
246
|
+
host_ids = [h["hostid"] for h in hosts]
|
|
247
|
+
host_name = {h["hostid"]: h["host"] for h in hosts}
|
|
248
|
+
items = client.get_items(host_ids, key=cat.item_key, key_search=cat.item_key_search)
|
|
249
|
+
if not items:
|
|
250
|
+
which = cat.item_key or cat.item_key_search
|
|
251
|
+
lines.append(f"No items matching '{which}' found.")
|
|
252
|
+
return lines
|
|
253
|
+
|
|
254
|
+
ranked = sorted(items, key=_item_value, reverse=True)
|
|
255
|
+
over = [it for it in ranked if cat.threshold is not None and _item_value(it) >= cat.threshold]
|
|
256
|
+
# Show every item at/over threshold, then fill to the cap with the highest.
|
|
257
|
+
shown = ranked[: max(BRIEF_ITEM_LIMIT, len(over))]
|
|
258
|
+
|
|
259
|
+
for item in shown:
|
|
260
|
+
host = host_name.get(item.get("hostid", ""), "")
|
|
261
|
+
name = item.get("name", "")
|
|
262
|
+
# For per-host gauges (item name == the configured key) the host already
|
|
263
|
+
# identifies the row; otherwise include the item name to disambiguate.
|
|
264
|
+
label = host
|
|
265
|
+
if name and name != cat.item_key:
|
|
266
|
+
label = f"{host} {name}".strip()
|
|
267
|
+
flag = " ⚠️" if cat.threshold is not None and _item_value(item) >= cat.threshold else ""
|
|
268
|
+
units = item.get("units") or ""
|
|
269
|
+
unit_str = f" {units}" if units else ""
|
|
270
|
+
lines.append(f"- {label}: {_fmt_value(item)}{unit_str}{flag} ({_fmt_time(item.get('lastclock'))})")
|
|
271
|
+
|
|
272
|
+
remaining = len(ranked) - len(shown)
|
|
273
|
+
if remaining > 0:
|
|
274
|
+
lines.append(f"- … and {remaining} more (≤ {_fmt_value(shown[-1])})")
|
|
275
|
+
return lines
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _brief_problem_category(client: ZapiClient, cat: Category, now_ts: int, recent_window: int) -> list[str]:
|
|
279
|
+
problems, total = _fetch_problems_with_total(client, tags=[tag_filter(cat.tag, cat.tag_value)])
|
|
280
|
+
noun = "problem" if total == 1 else "problems"
|
|
281
|
+
lines = [f"\n## {cat.name} ({_count_fragment(len(problems), total)} active {noun})"]
|
|
282
|
+
if not problems:
|
|
283
|
+
lines.append("No active problems.")
|
|
284
|
+
return lines
|
|
285
|
+
_emit_problem_bucket(lines, problems, now_ts, recent_window, with_severity=True)
|
|
286
|
+
return lines
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@mcp.tool()
|
|
290
|
+
def daily_brief() -> str:
|
|
291
|
+
"""Morning patrol summary.
|
|
292
|
+
|
|
293
|
+
Reports active problems (Warning and above), then one section per category
|
|
294
|
+
configured via ZABBIX_CATEGORIES_INI (e.g. DHCP pool usage, SNAT session
|
|
295
|
+
usage, core-network problems). Item-based categories show current values
|
|
296
|
+
sorted high-to-low; problem-based categories list active problems.
|
|
297
|
+
|
|
298
|
+
Problems are listed newest-first with their age; those older than the recent
|
|
299
|
+
window (ZABBIX_BRIEF_RECENT_HOURS, default 24h) are folded to a count so a
|
|
300
|
+
long-standing backlog of un-recovered fossils doesn't bury today's events.
|
|
301
|
+
Section headers show the true total ('showing N of TOTAL' when capped).
|
|
302
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
client = _client()
|
|
305
|
+
except KeyError as e:
|
|
306
|
+
return f"Missing environment variable: {e}"
|
|
307
|
+
except ZapiError as e:
|
|
308
|
+
reset_client()
|
|
309
|
+
return f"Zabbix error: {e}"
|
|
310
|
+
|
|
311
|
+
now_dt = datetime.now().astimezone()
|
|
312
|
+
now_ts = int(now_dt.timestamp())
|
|
313
|
+
recent_window = _recent_window_seconds()
|
|
314
|
+
window_label = _window_label(recent_window)
|
|
315
|
+
lines = [f"# Daily Brief — {now_dt.strftime('%Y-%m-%d %H:%M')}"]
|
|
316
|
+
|
|
317
|
+
# Active problems (Warning and above), newest first; stale ones folded away.
|
|
318
|
+
try:
|
|
319
|
+
problems, total = _fetch_problems_with_total(client, severities=SEVERITY_WARNING_AND_ABOVE)
|
|
320
|
+
lines.append(f"\n## Active Problems ({_count_fragment(len(problems), total)})")
|
|
321
|
+
if not problems:
|
|
322
|
+
lines.append("No active problems.")
|
|
323
|
+
else:
|
|
324
|
+
by_sev: dict[int, list] = {}
|
|
325
|
+
for p in problems:
|
|
326
|
+
by_sev.setdefault(int(p["severity"]), []).append(p)
|
|
327
|
+
for sev in sorted(by_sev, reverse=True):
|
|
328
|
+
bucket = by_sev[sev]
|
|
329
|
+
n_recent = sum(1 for p in bucket if now_ts - _clock(p) <= recent_window)
|
|
330
|
+
lines.append(f"\n### {_severity_name(sev)} ({len(bucket)}, {n_recent} in last {window_label})")
|
|
331
|
+
_emit_problem_bucket(lines, bucket, now_ts, recent_window)
|
|
332
|
+
except ZapiError as e:
|
|
333
|
+
# First real call failed (often a stale token); drop the client so the
|
|
334
|
+
# next invocation re-authenticates.
|
|
335
|
+
reset_client()
|
|
336
|
+
lines.append(f"\n## Active Problems\nError: {e}")
|
|
337
|
+
return "\n".join(lines)
|
|
338
|
+
|
|
339
|
+
# Per-category sections
|
|
340
|
+
categories = load_categories()
|
|
341
|
+
if not categories:
|
|
342
|
+
lines.append(
|
|
343
|
+
"\n(No categories configured. Set ZABBIX_CATEGORIES_INI to add "
|
|
344
|
+
"DHCP / SNAT / core-network sections — see categories.ini.example.)"
|
|
345
|
+
)
|
|
346
|
+
for cat in categories:
|
|
347
|
+
try:
|
|
348
|
+
if cat.kind == "items":
|
|
349
|
+
lines.extend(_brief_item_category(client, cat))
|
|
350
|
+
else:
|
|
351
|
+
lines.extend(_brief_problem_category(client, cat, now_ts, recent_window))
|
|
352
|
+
except ZapiError as e:
|
|
353
|
+
lines.append(f"\n## {cat.name}\nError: {e}")
|
|
354
|
+
|
|
355
|
+
return "\n".join(lines)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
# Problems
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
@mcp.tool()
|
|
362
|
+
def get_problems(
|
|
363
|
+
min_severity: int = 2,
|
|
364
|
+
tag_name: str | None = None,
|
|
365
|
+
tag_value: str | None = None,
|
|
366
|
+
limit: int = 50,
|
|
367
|
+
) -> str:
|
|
368
|
+
"""Get active Zabbix problems, newest first.
|
|
369
|
+
|
|
370
|
+
Problems are listed newest-first and annotated with their age. The header
|
|
371
|
+
shows the true total ('showing N of TOTAL' when the result is capped by
|
|
372
|
+
`limit`), so a capped listing is never mistaken for the full picture.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
min_severity: Minimum severity (0=Not classified, 1=Info, 2=Warning, 3=Average, 4=High, 5=Disaster)
|
|
376
|
+
tag_name: Filter by tag name (optional)
|
|
377
|
+
tag_value: Filter by tag value (optional, requires tag_name)
|
|
378
|
+
limit: Maximum number of problems to return (floored at 1; when the result
|
|
379
|
+
hits this cap a second count query is issued to report the true total)
|
|
380
|
+
"""
|
|
381
|
+
if min_severity > MAX_SEVERITY:
|
|
382
|
+
return "No problems at/above the requested severity."
|
|
383
|
+
try:
|
|
384
|
+
client = _client()
|
|
385
|
+
severities = list(range(max(min_severity, 0), 6))
|
|
386
|
+
tags = [tag_filter(tag_name, tag_value)] if tag_name else None
|
|
387
|
+
problems, total = _fetch_problems_with_total(client, severities=severities, tags=tags, limit=max(1, limit))
|
|
388
|
+
except KeyError as e:
|
|
389
|
+
return f"Missing environment variable: {e}"
|
|
390
|
+
except ZapiError as e:
|
|
391
|
+
reset_client()
|
|
392
|
+
return f"Zabbix error: {e}"
|
|
393
|
+
if not problems:
|
|
394
|
+
return "No active problems."
|
|
395
|
+
now_ts = int(datetime.now().astimezone().timestamp())
|
|
396
|
+
problems = sorted(problems, key=_clock, reverse=True)
|
|
397
|
+
lines = [f"Active Problems ({_count_fragment(len(problems), total)}):"]
|
|
398
|
+
for p in problems:
|
|
399
|
+
lines.append(_problem_line(p, now_ts, with_severity=True))
|
|
400
|
+
tag_str = _fmt_tags(p.get("tags", []))
|
|
401
|
+
if tag_str:
|
|
402
|
+
lines.append(f" tags: {tag_str}")
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
# Hosts
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
@mcp.tool()
|
|
410
|
+
def get_hosts(
|
|
411
|
+
role: str | None = None,
|
|
412
|
+
tag_name: str | None = None,
|
|
413
|
+
tag_value: str | None = None,
|
|
414
|
+
group: str | None = None,
|
|
415
|
+
) -> str:
|
|
416
|
+
"""List Zabbix hosts filtered by tag or group.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
role: Filter by role tag value (e.g. 'main', 'edge')
|
|
420
|
+
tag_name: Filter by arbitrary tag name
|
|
421
|
+
tag_value: Filter by tag value (requires tag_name)
|
|
422
|
+
group: Filter by host group name
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
client = _client()
|
|
426
|
+
tags = []
|
|
427
|
+
if role:
|
|
428
|
+
tags.append(tag_filter("role", role))
|
|
429
|
+
if tag_name:
|
|
430
|
+
tags.append(tag_filter(tag_name, tag_value))
|
|
431
|
+
hosts = client.get_hosts(tags=tags or None, group=group)
|
|
432
|
+
except KeyError as e:
|
|
433
|
+
return f"Missing environment variable: {e}"
|
|
434
|
+
except ZapiError as e:
|
|
435
|
+
reset_client()
|
|
436
|
+
return f"Zabbix error: {e}"
|
|
437
|
+
if not hosts:
|
|
438
|
+
return "No hosts found."
|
|
439
|
+
lines = [f"Hosts ({len(hosts)}):"]
|
|
440
|
+
for h in sorted(hosts, key=lambda x: x["host"]):
|
|
441
|
+
tag_str = _fmt_tags(h.get("tags", []))
|
|
442
|
+
interfaces = h.get("interfaces") or [{}]
|
|
443
|
+
ip = interfaces[0].get("ip", "—")
|
|
444
|
+
lines.append(f" {h['host']} {ip} [{tag_str}]")
|
|
445
|
+
return "\n".join(lines)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ------------------------------------------------------------------
|
|
449
|
+
# Items (current values)
|
|
450
|
+
# ------------------------------------------------------------------
|
|
451
|
+
@mcp.tool()
|
|
452
|
+
def get_host_items(host: str, search: str | None = None) -> str:
|
|
453
|
+
"""Get current item values for a host.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
host: Hostname (exact match)
|
|
457
|
+
search: Filter items by name (partial match)
|
|
458
|
+
"""
|
|
459
|
+
try:
|
|
460
|
+
client = _client()
|
|
461
|
+
hosts = client.get_hosts(host=host)
|
|
462
|
+
if not hosts:
|
|
463
|
+
return f"Host '{host}' not found."
|
|
464
|
+
items = client.get_items([hosts[0]["hostid"]], name_search=search)
|
|
465
|
+
except KeyError as e:
|
|
466
|
+
return f"Missing environment variable: {e}"
|
|
467
|
+
except ZapiError as e:
|
|
468
|
+
reset_client()
|
|
469
|
+
return f"Zabbix error: {e}"
|
|
470
|
+
if not items:
|
|
471
|
+
return f"No items found for '{host}'."
|
|
472
|
+
lines = [f"Items for {host} ({len(items)}):"]
|
|
473
|
+
for item in sorted(items, key=lambda x: x["name"]):
|
|
474
|
+
ts = _fmt_time(item.get("lastclock"))
|
|
475
|
+
val = item.get("lastvalue") or "—"
|
|
476
|
+
lines.append(f" {item['name']}: {val} {item.get('units', '')} ({ts})")
|
|
477
|
+
return "\n".join(lines)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ------------------------------------------------------------------
|
|
481
|
+
# Acknowledge
|
|
482
|
+
# ------------------------------------------------------------------
|
|
483
|
+
@mcp.tool()
|
|
484
|
+
def acknowledge_problem(event_ids: str, message: str) -> str:
|
|
485
|
+
"""Acknowledge Zabbix problems and add a message (does not close them).
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
event_ids: Comma-separated event IDs (from get_problems output)
|
|
489
|
+
message: Acknowledgement message
|
|
490
|
+
"""
|
|
491
|
+
ids = [eid.strip() for eid in event_ids.split(",") if eid.strip()]
|
|
492
|
+
if not ids:
|
|
493
|
+
return "No event IDs provided."
|
|
494
|
+
try:
|
|
495
|
+
client = _client()
|
|
496
|
+
result = client.acknowledge_problem(ids, message)
|
|
497
|
+
except KeyError as e:
|
|
498
|
+
return f"Missing environment variable: {e}"
|
|
499
|
+
except ZapiError as e:
|
|
500
|
+
reset_client()
|
|
501
|
+
return f"Zabbix error: {e}"
|
|
502
|
+
return f"Acknowledged {len(ids)} event(s): {result}"
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zapi-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server for Zabbix API — daily brief, problems, hosts, items
|
|
5
|
+
Author: AIKAWA Shigechika
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/shigechika/zapi-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/shigechika/zapi-mcp
|
|
9
|
+
Project-URL: Issues, https://github.com/shigechika/zapi-mcp/issues
|
|
10
|
+
Keywords: zabbix,mcp,model-context-protocol,monitoring,network
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: System :: Monitoring
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: mcp>=1.0
|
|
22
|
+
Requires-Dist: httpx
|
|
23
|
+
|
|
24
|
+
<!-- mcp-name: io.github.shigechika/zapi-mcp -->
|
|
25
|
+
|
|
26
|
+
# zapi-mcp
|
|
27
|
+
|
|
28
|
+
English | [日本語](README.ja.md)
|
|
29
|
+
|
|
30
|
+
MCP (Model Context Protocol) server for the [Zabbix](https://www.zabbix.com/) API.
|
|
31
|
+
|
|
32
|
+
Built for network operations: a single `daily_brief` call summarizes active
|
|
33
|
+
problems plus site-specific categories (DHCP pool usage, SNAT session usage,
|
|
34
|
+
core-network problems, …), and individual tools query problems, hosts, and item
|
|
35
|
+
values. Organization-specific tags live in a config file, not the code, so the
|
|
36
|
+
server stays generic.
|
|
37
|
+
|
|
38
|
+
Version-adaptive auth: works against Zabbix 6.0 LTS (`user` + `auth` field) and
|
|
39
|
+
forward-compatible with 6.4 / 7.0 (`username` + `Authorization: Bearer`).
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
| Tool | Description |
|
|
44
|
+
|------|-------------|
|
|
45
|
+
| `daily_brief` | Morning patrol: active problems (Warning+) plus one section per configured category |
|
|
46
|
+
| `get_problems` | Active problems by severity and tag, newest-first with age; header shows the true total (`showing N of TOTAL` when capped); output includes `eventid` |
|
|
47
|
+
| `get_hosts` | List hosts filtered by role/tag/group, with IP and tags |
|
|
48
|
+
| `get_host_items` | Current item values for a host (server-side host filter) |
|
|
49
|
+
| `acknowledge_problem` | Acknowledge problems and add a message (does not close them) |
|
|
50
|
+
|
|
51
|
+
## Setup
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# uv
|
|
55
|
+
uv pip install zapi-mcp
|
|
56
|
+
|
|
57
|
+
# pip
|
|
58
|
+
pip install zapi-mcp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or from source:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/shigechika/zapi-mcp.git
|
|
65
|
+
cd zapi-mcp
|
|
66
|
+
|
|
67
|
+
# uv
|
|
68
|
+
uv sync
|
|
69
|
+
|
|
70
|
+
# pip
|
|
71
|
+
pip install -e .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
Set the following environment variables:
|
|
77
|
+
|
|
78
|
+
| Variable | Description | Default |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `ZABBIX_URL` | Zabbix base URL (e.g. `https://zabbix.example.com`); `/api_jsonrpc.php` is appended if absent | *required* |
|
|
81
|
+
| `ZABBIX_USER` | Zabbix API user | *required* |
|
|
82
|
+
| `ZABBIX_PASSWORD` | Zabbix API password | *required* |
|
|
83
|
+
| `ZABBIX_CATEGORIES_INI` | Path to a categories INI file for `daily_brief` (optional) | — |
|
|
84
|
+
| `ZABBIX_BRIEF_RECENT_HOURS` | `daily_brief` "recent" window in hours; problems older than this are folded to a count | `24` |
|
|
85
|
+
| `ZABBIX_BRIEF_PROBLEM_LIMIT` | Max active problems `daily_brief` fetches per call before counting the rest | `1000` |
|
|
86
|
+
|
|
87
|
+
The API user needs read permission for the host groups you query, plus
|
|
88
|
+
acknowledge permission if you use `acknowledge_problem`.
|
|
89
|
+
|
|
90
|
+
### Active problems in `daily_brief`
|
|
91
|
+
|
|
92
|
+
Problems are grouped by severity and listed **newest-first**, each annotated with
|
|
93
|
+
its age (e.g. `3h ago`). Problems older than the recent window
|
|
94
|
+
(`ZABBIX_BRIEF_RECENT_HOURS`, default 24h) are folded to a single
|
|
95
|
+
`… and N older (stale; oldest …)` line — so a backlog of alerts that Zabbix
|
|
96
|
+
keeps active because their recovery is never auto-confirmed (ICMP ping down, RDP
|
|
97
|
+
down, …) doesn't bury what just happened. Section headers carry the true total
|
|
98
|
+
and show `showing N of TOTAL` when the fetch is capped, never a silent truncation.
|
|
99
|
+
|
|
100
|
+
### Categories for `daily_brief` (optional)
|
|
101
|
+
|
|
102
|
+
`daily_brief` always lists active problems. To add site-specific sections —
|
|
103
|
+
DHCP pool exhaustion, SNAT session usage, core-network problems — point
|
|
104
|
+
`ZABBIX_CATEGORIES_INI` at an INI file. Each `[section]` is one category:
|
|
105
|
+
|
|
106
|
+
```ini
|
|
107
|
+
[dhcp]
|
|
108
|
+
name = DHCP Pool Usage
|
|
109
|
+
tag = dhcp-pool-usage ; Zabbix host tag identifying the group
|
|
110
|
+
item_key = usage ; report current values for this exact item key
|
|
111
|
+
threshold = 80 ; flag values >= this
|
|
112
|
+
|
|
113
|
+
[snat]
|
|
114
|
+
name = SNAT Session Pool
|
|
115
|
+
tag = snat-pool-usage
|
|
116
|
+
item_key_search = .usage ; substring match (catches pool.node0.usage etc.)
|
|
117
|
+
threshold = 80
|
|
118
|
+
|
|
119
|
+
[core]
|
|
120
|
+
name = Core Network
|
|
121
|
+
tag = role
|
|
122
|
+
tag_value = main ; tag must equal this value
|
|
123
|
+
; no item key -> report active problems instead
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- `tag` (required): host tag identifying the category. With `tag_value`, the tag
|
|
127
|
+
must equal it (Equal); without, any host carrying the tag matches (Exists).
|
|
128
|
+
- `item_key` / `item_key_search`: when either is set, the section reports current
|
|
129
|
+
item values sorted high-to-low. `item_key` matches the key exactly; use
|
|
130
|
+
`item_key_search` for keys that embed an id (e.g. `.usage` catches
|
|
131
|
+
`pool.node0.usage`). When neither is set, it reports active problems for the tag.
|
|
132
|
+
- `threshold`: optional; values at or above it are flagged.
|
|
133
|
+
|
|
134
|
+
See [`categories.ini.example`](categories.ini.example). When the variable is
|
|
135
|
+
unset or the file is missing, `daily_brief` reports active problems only.
|
|
136
|
+
|
|
137
|
+
## Usage
|
|
138
|
+
|
|
139
|
+
### Claude Code
|
|
140
|
+
|
|
141
|
+
Add to `.mcp.json`:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"mcpServers": {
|
|
146
|
+
"zapi-mcp": {
|
|
147
|
+
"type": "stdio",
|
|
148
|
+
"command": "zapi-mcp",
|
|
149
|
+
"env": {
|
|
150
|
+
"ZABBIX_URL": "https://zabbix.example.com",
|
|
151
|
+
"ZABBIX_USER": "api-user",
|
|
152
|
+
"ZABBIX_PASSWORD": "",
|
|
153
|
+
"ZABBIX_CATEGORIES_INI": "/path/to/categories.ini"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Claude Desktop
|
|
161
|
+
|
|
162
|
+
Add to `claude_desktop_config.json`:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"mcpServers": {
|
|
167
|
+
"zapi-mcp": {
|
|
168
|
+
"command": "zapi-mcp",
|
|
169
|
+
"env": {
|
|
170
|
+
"ZABBIX_URL": "https://zabbix.example.com",
|
|
171
|
+
"ZABBIX_USER": "api-user",
|
|
172
|
+
"ZABBIX_PASSWORD": ""
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Direct Execution
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
export ZABBIX_URL=https://zabbix.example.com
|
|
183
|
+
export ZABBIX_USER=api-user
|
|
184
|
+
export ZABBIX_PASSWORD=your-password
|
|
185
|
+
zapi-mcp
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### CLI Options
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
zapi-mcp --version # Print version and exit
|
|
192
|
+
zapi-mcp --check # Verify environment variables and authentication, then exit
|
|
193
|
+
zapi-mcp --brief # Print the daily_brief to stdout and exit (handy for cron)
|
|
194
|
+
zapi-mcp # Start MCP server (STDIO, default)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`--check` exit codes: `0` success, `1` config error, `2` auth/connection error.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
git clone https://github.com/shigechika/zapi-mcp.git
|
|
203
|
+
cd zapi-mcp
|
|
204
|
+
|
|
205
|
+
# uv
|
|
206
|
+
uv sync --dev
|
|
207
|
+
uv run pytest -v
|
|
208
|
+
uv run ruff check .
|
|
209
|
+
|
|
210
|
+
# pip
|
|
211
|
+
python3 -m venv .venv
|
|
212
|
+
.venv/bin/pip install -e . && .venv/bin/pip install pytest pytest-cov respx ruff
|
|
213
|
+
.venv/bin/pytest -v
|
|
214
|
+
.venv/bin/ruff check .
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Releasing
|
|
218
|
+
|
|
219
|
+
Releases are automated with [release-please](https://github.com/googleapis/release-please).
|
|
220
|
+
Merging [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, …)
|
|
221
|
+
to `main` keeps a release PR open with the next version and changelog. Merging
|
|
222
|
+
that PR tags `vX.Y.Z` and publishes a GitHub Release, whose `release: published`
|
|
223
|
+
event triggers the `release` workflow to build and publish to PyPI and the MCP
|
|
224
|
+
Registry. release-please owns the version in `zapi_mcp/__init__.py` and
|
|
225
|
+
`server.json` (do not bump them by hand).
|
|
226
|
+
|
|
227
|
+
> [!IMPORTANT]
|
|
228
|
+
> The release-please workflow should be given a repository secret
|
|
229
|
+
> `RELEASE_PLEASE_TOKEN` (a PAT with `contents: write` + `pull-requests: write`).
|
|
230
|
+
> The default `GITHUB_TOKEN` cannot create the Release that triggers the
|
|
231
|
+
> downstream `release` workflow (GitHub blocks workflow runs triggered by
|
|
232
|
+
> `GITHUB_TOKEN`), so without the PAT nothing gets published. The workflow falls
|
|
233
|
+
> back to `GITHUB_TOKEN` when the secret is unset so PR CI keeps working on forks.
|
|
234
|
+
|
|
235
|
+
## Roadmap
|
|
236
|
+
|
|
237
|
+
- Streamable HTTP transport + OAuth2 for remote / mobile use
|
|
238
|
+
- Visual rendering of key metrics
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
zapi_mcp/__init__.py,sha256=zpDQ5Yy87YovTsqwJthd7a6Tq9300bpj3g7N8Zvv0Gg,93
|
|
2
|
+
zapi_mcp/__main__.py,sha256=yvT4cn5BS00Gwq5zKlS0h43hsLJZaXp5Jbwq8nLxTBI,2061
|
|
3
|
+
zapi_mcp/categories.py,sha256=_mlbZB4uSrSFSsduv6MfknlUGCqzzqZis3sQ9hEbKNQ,2540
|
|
4
|
+
zapi_mcp/client.py,sha256=nmK2HSi9qel4Ve_sU4oRPMcozKauPF5X026qNC-zKU0,11257
|
|
5
|
+
zapi_mcp/server.py,sha256=AQ1dF35lkzlO9yAVYq5ab9qWegxzyN0-WV8hAX6P8lo,18610
|
|
6
|
+
zapi_mcp-0.2.0.dist-info/METADATA,sha256=BWHwKxrmdPEIKTQgrdi-sQU59Ka0K1wdKYKAOswBY7E,7932
|
|
7
|
+
zapi_mcp-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
zapi_mcp-0.2.0.dist-info/entry_points.txt,sha256=sOVZtazLbeClsjTCd-W3xp_WW5sv9ZII2yvZcm9arWw,52
|
|
9
|
+
zapi_mcp-0.2.0.dist-info/top_level.txt,sha256=PvL5gJQP3TPZtbawoDIs_lyv5HJ70swzPHZPv17JXZ4,9
|
|
10
|
+
zapi_mcp-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zapi_mcp
|