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 ADDED
@@ -0,0 +1,3 @@
1
+ """zapi-mcp — Zabbix API MCP Server."""
2
+
3
+ __version__ = "0.2.0" # x-release-please-version
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zapi-mcp = zapi_mcp.__main__:main
@@ -0,0 +1 @@
1
+ zapi_mcp