node804-hpe-msa 0.1.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.
Files changed (42) hide show
  1. hpe_msa_mcp/__init__.py +14 -0
  2. hpe_msa_mcp/__main__.py +20 -0
  3. hpe_msa_mcp/_redact.py +75 -0
  4. hpe_msa_mcp/client.py +395 -0
  5. hpe_msa_mcp/config/__init__.py +0 -0
  6. hpe_msa_mcp/config/arrays.py +591 -0
  7. hpe_msa_mcp/py.typed +0 -0
  8. hpe_msa_mcp/responses/__init__.py +0 -0
  9. hpe_msa_mcp/responses/shapers.py +595 -0
  10. hpe_msa_mcp/server.py +123 -0
  11. hpe_msa_mcp/setup.py +781 -0
  12. hpe_msa_mcp/skills/hpe-msa-engineer/SKILL.md +97 -0
  13. hpe_msa_mcp/skills/hpe-msa-engineer/references/events-alerts.md +43 -0
  14. hpe_msa_mcp/skills/hpe-msa-engineer/references/health-model.md +54 -0
  15. hpe_msa_mcp/skills/hpe-msa-engineer/references/hosts-mapping.md +60 -0
  16. hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/capacity-review.md +36 -0
  17. hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/disk-failure-triage.md +42 -0
  18. hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/event-triage.md +37 -0
  19. hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/firmware-inventory.md +30 -0
  20. hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/health-check.md +38 -0
  21. hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/mapping-audit.md +39 -0
  22. hpe_msa_mcp/skills/hpe-msa-engineer/references/playbooks/performance-review.md +41 -0
  23. hpe_msa_mcp/skills/hpe-msa-engineer/references/statistics.md +55 -0
  24. hpe_msa_mcp/skills/hpe-msa-engineer/references/storage-model.md +64 -0
  25. hpe_msa_mcp/skills/hpe-msa-engineer/references/tool-map.md +86 -0
  26. hpe_msa_mcp/skills/hpe-msa-engineer/scripts/README.md +5 -0
  27. hpe_msa_mcp/tools/__init__.py +0 -0
  28. hpe_msa_mcp/tools/_common.py +191 -0
  29. hpe_msa_mcp/tools/aggregations.py +152 -0
  30. hpe_msa_mcp/tools/events.py +106 -0
  31. hpe_msa_mcp/tools/hardware.py +160 -0
  32. hpe_msa_mcp/tools/hosts.py +138 -0
  33. hpe_msa_mcp/tools/statistics.py +88 -0
  34. hpe_msa_mcp/tools/status.py +76 -0
  35. hpe_msa_mcp/tools/storage.py +118 -0
  36. hpe_msa_mcp/tools/system.py +190 -0
  37. hpe_msa_mcp/tools/utility.py +143 -0
  38. node804_hpe_msa-0.1.0.dist-info/METADATA +461 -0
  39. node804_hpe_msa-0.1.0.dist-info/RECORD +42 -0
  40. node804_hpe_msa-0.1.0.dist-info/WHEEL +4 -0
  41. node804_hpe_msa-0.1.0.dist-info/entry_points.txt +3 -0
  42. node804_hpe_msa-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,14 @@
1
+ """Token-efficient, read-only MCP server for HPE MSA (Modular Smart Array) storage.
2
+
3
+ Designed around three principles:
4
+ 1. Lean responses by default — typical 40-70% token reduction vs unfiltered output.
5
+ 2. Default-deny RBAC — only read tools are exposed; MSA_MODE never registers writes.
6
+ 3. TLS verification on by default — MSA controller certs are often self-signed,
7
+ so disabling verification requires an explicit opt-in.
8
+
9
+ See the README for installation, configuration, and the full tool catalog.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __version__ = "0.1.0"
@@ -0,0 +1,20 @@
1
+ """Entry point for ``python -m hpe_msa_mcp``.
2
+
3
+ This file makes the package runnable as a module — useful when an MCP host
4
+ config prefers to specify a Python interpreter explicitly rather than rely
5
+ on the ``node804-hpe-msa`` console script being on the user's PATH.
6
+
7
+ Both invocations call the same ``main()`` function:
8
+
9
+ node804-hpe-msa # via [project.scripts] console script
10
+ python -m hpe_msa_mcp # via this file
11
+
12
+ Pick whichever is easier to configure in your MCP host.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .server import main
18
+
19
+ if __name__ == "__main__":
20
+ main()
hpe_msa_mcp/_redact.py ADDED
@@ -0,0 +1,75 @@
1
+ """Secret-redaction helpers for error messages and log lines.
2
+
3
+ The HPE MSA REST API authenticates in two stages that both put secrets into
4
+ request URLs and headers:
5
+
6
+ 1. Login hashes ``username_password`` and sends it as a URL path segment
7
+ (``GET /api/login/<hash>``). httpx and the underlying transport can echo
8
+ that full URL into exception strings on a TLS or transport failure, so
9
+ the login hash (from which the password can be brute-forced offline) can
10
+ leak into logs.
11
+ 2. Every subsequent call carries a ``sessionKey`` header. Some error paths
12
+ stringify the request (including headers), surfacing the live session key.
13
+
14
+ This module scrubs both from arbitrary text. Every code path that turns an
15
+ exception into a log line or a tool response runs the result through here.
16
+
17
+ The redaction is intentionally conservative — regex-only, so there is no
18
+ parsing surface to abuse, and it preserves surrounding context so log readers
19
+ can still see where the event happened.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from typing import Final
26
+
27
+ REDACTED: Final = "***REDACTED***"
28
+
29
+ # ``/api/login/<hash>`` — the hash is a hex SHA-256 (64 chars) or hex MD5 (32
30
+ # chars). Match a generous hex run after the login path so either firmware's
31
+ # hash is scrubbed, value only, path structure preserved.
32
+ _LOGIN_HASH_RE: Final = re.compile(
33
+ r"(?P<prefix>/api/login/)(?P<val>[0-9a-fA-F]{16,})",
34
+ )
35
+
36
+ # ``sessionKey`` however it is spelled in a header dump or repr:
37
+ # ``sessionKey: ABC...``, ``'sessionKey': 'ABC...'``, ``sessionKey=ABC...``.
38
+ _SESSION_KEY_RE: Final = re.compile(
39
+ r"(?P<name>sessionKey)(?P<sep>['\"]?\s*[:=]\s*['\"]?)(?P<val>[A-Za-z0-9+/=_\-]{8,})",
40
+ flags=re.IGNORECASE,
41
+ )
42
+
43
+ # Bare ``password=...`` / ``password: ...`` assignments that can appear in a
44
+ # config repr or a stray debug line. Defense in depth — the password should
45
+ # never reach a log, but if it does, scrub it.
46
+ _PASSWORD_RE: Final = re.compile(
47
+ r"(?P<name>password)(?P<sep>['\"]?\s*[:=]\s*['\"]?)(?P<val>[^\s'\"<>&]+)",
48
+ flags=re.IGNORECASE,
49
+ )
50
+
51
+
52
+ def redact_secrets(text: str) -> str:
53
+ """Strip login hashes, session keys, and passwords from a string.
54
+
55
+ Safe to call on any string — if no secret patterns match, the input is
56
+ returned unchanged. Cheap enough to apply liberally on every error path.
57
+ """
58
+ if not text:
59
+ return text
60
+ text = _LOGIN_HASH_RE.sub(lambda m: f"{m.group('prefix')}{REDACTED}", text)
61
+ text = _SESSION_KEY_RE.sub(lambda m: f"{m.group('name')}{m.group('sep')}{REDACTED}", text)
62
+ text = _PASSWORD_RE.sub(lambda m: f"{m.group('name')}{m.group('sep')}{REDACTED}", text)
63
+ return text
64
+
65
+
66
+ def redact_exception(exc: BaseException) -> str:
67
+ """Render an exception as a redacted string suitable for logs/responses.
68
+
69
+ Falls back to the class name when ``str(exc)`` is empty, so a bare
70
+ exception type still produces a useful message.
71
+ """
72
+ return redact_secrets(str(exc)) or exc.__class__.__name__
73
+
74
+
75
+ __all__ = ["REDACTED", "redact_exception", "redact_secrets"]
hpe_msa_mcp/client.py ADDED
@@ -0,0 +1,395 @@
1
+ """HTTP client for the HPE MSA REST management API, with session caching.
2
+
3
+ Unlike the SDK-backed servers in the suite (panos wraps ``pan-os-python``),
4
+ HPE MSA ships no Python SDK, so this module talks to the array's REST API
5
+ directly over ``httpx``.
6
+
7
+ MSA auth is a **session key**, not a long-lived API key:
8
+
9
+ 1. Hash ``"<username>_<password>"`` (SHA-256, MD5 fallback for old firmware).
10
+ 2. ``GET /api/login/<hash>`` returns a ``sessionKey`` in its status object.
11
+ 3. Every subsequent call sends that key as the ``sessionKey`` header.
12
+ 4. The key idles out (~30 min); an expired-session response triggers one
13
+ transparent re-login + retry.
14
+
15
+ So this client owns login, session-key caching, and re-login-on-expiry.
16
+ :class:`MSAClient` instances are cached for the server-process lifetime, keyed
17
+ by array name, so a session logs in once per array and reuses the key.
18
+
19
+ Errors are returned as :class:`ResolveError` (``.message``) rather than raised,
20
+ so tools surface a clean string to the model instead of a stack trace. Every
21
+ error string passes through :func:`redact_exception` so a leaked login hash or
22
+ session key never reaches a log.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import hashlib
29
+ import os
30
+ from dataclasses import dataclass, field
31
+ from typing import Any
32
+
33
+ import httpx
34
+ from node804_mcp_toolkit import TlsConfig
35
+
36
+ from ._redact import redact_exception, redact_secrets
37
+ from .config.arrays import ArrayEntry, resolve_array
38
+
39
+ # A single stuck request should fail fast rather than hang the whole MCP
40
+ # process — every read tool calls the array synchronously, so an unbounded
41
+ # request blocks even local tools like server_status. Bounded per request,
42
+ # overridable via MSA_API_TIMEOUT.
43
+ _DEFAULT_API_TIMEOUT = 30
44
+
45
+
46
+ def _api_timeout() -> float:
47
+ """Per-request API timeout in seconds (override via ``MSA_API_TIMEOUT``)."""
48
+ raw = os.environ.get("MSA_API_TIMEOUT")
49
+ if raw:
50
+ try:
51
+ value = float(raw)
52
+ if value > 0:
53
+ return value
54
+ except ValueError:
55
+ pass
56
+ return _DEFAULT_API_TIMEOUT
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ResolveError:
61
+ """Returned when a request can't be completed (resolve/login/transport).
62
+
63
+ Tools surface ``ResolveError.message`` to the model rather than raising,
64
+ so the caller gets a clean, already-redacted error string.
65
+ """
66
+
67
+ message: str
68
+
69
+
70
+ # MSA marks an expired/invalid session with this return code; the response
71
+ # also comes back as HTTP 401 on most firmware. Either triggers one re-login.
72
+ _SESSION_EXPIRED_CODES = frozenset({-10005, -10000})
73
+
74
+
75
+ def _build_verify(tls: TlsConfig) -> bool | str:
76
+ """Translate our TLS config into httpx's ``verify`` argument."""
77
+ if not tls.verify:
78
+ return False
79
+ if tls.ca_path is not None and tls.ca_bundle is not None:
80
+ return str(tls.ca_path)
81
+ return True
82
+
83
+
84
+ def _extract_status(payload: Any) -> dict[str, Any]:
85
+ """Return the trailing ``status`` object from an MSA response, or ``{}``.
86
+
87
+ Every MSA response ends with a ``status`` array describing the command
88
+ result; the last element is the authoritative one.
89
+ """
90
+ if not isinstance(payload, dict):
91
+ return {}
92
+ status = payload.get("status")
93
+ if isinstance(status, list) and status:
94
+ last = status[-1]
95
+ return last if isinstance(last, dict) else {}
96
+ if isinstance(status, dict):
97
+ return status
98
+ return {}
99
+
100
+
101
+ def _status_is_error(status: dict[str, Any]) -> bool:
102
+ """True when a status object reports a command failure."""
103
+ rtype = str(status.get("response-type", "")).strip().lower()
104
+ if rtype == "error":
105
+ return True
106
+ # Some firmware omits response-type but sets a negative return-code.
107
+ code = status.get("return-code")
108
+ return isinstance(code, int) and code < 0
109
+
110
+
111
+ def _status_is_expired(status: dict[str, Any]) -> bool:
112
+ """True when a status object indicates an expired/invalid session."""
113
+ code = status.get("return-code")
114
+ if isinstance(code, int) and code in _SESSION_EXPIRED_CODES:
115
+ return True
116
+ response = str(status.get("response", "")).lower()
117
+ return "session" in response and ("expired" in response or "invalid" in response)
118
+
119
+
120
+ @dataclass
121
+ class MSAClient:
122
+ """A logged-in connection to one MSA array, cached for the process lifetime.
123
+
124
+ ``hosts`` is the failover-ordered list of management addresses for the one
125
+ array (a VIP, or the two controller IPs). Login walks the list and settles
126
+ on the first host that answers; that host is then used for every subsequent
127
+ request until the session expires, at which point failover runs again.
128
+ """
129
+
130
+ name: str
131
+ hosts: list[str]
132
+ username: str
133
+ password: str
134
+ tls: TlsConfig
135
+ timeout: float = field(default_factory=_api_timeout)
136
+ _active_index: int = field(default=0, init=False, repr=False)
137
+ _session_key: str | None = field(default=None, init=False, repr=False)
138
+ _http: httpx.Client | None = field(default=None, init=False, repr=False)
139
+
140
+ # --- HTTP plumbing ------------------------------------------------------
141
+
142
+ @property
143
+ def host(self) -> str:
144
+ """The management address currently in use (the active failover host)."""
145
+ return self.hosts[self._active_index]
146
+
147
+ @property
148
+ def base_url(self) -> str:
149
+ return f"https://{self.host}/api"
150
+
151
+ def _http_client(self) -> httpx.Client:
152
+ if self._http is None or self._http.is_closed:
153
+ self._http = httpx.Client(
154
+ base_url=self.base_url,
155
+ verify=_build_verify(self.tls),
156
+ timeout=self.timeout,
157
+ headers={"datatype": "json"},
158
+ )
159
+ return self._http
160
+
161
+ def _activate_host(self, index: int) -> None:
162
+ """Switch the active host, rebuilding the HTTP client against its base URL."""
163
+ if index != self._active_index and self._http is not None and not self._http.is_closed:
164
+ self._http.close()
165
+ self._http = None
166
+ self._active_index = index
167
+
168
+ def _login_hashes(self) -> list[str]:
169
+ """Login hashes to try, in order: SHA-256 first, MD5 fallback.
170
+
171
+ Gen5/Gen6 support SHA-256; only much older firmware is MD5-only. We try
172
+ the strong hash first and fall back so a mixed/old array still works.
173
+ """
174
+ credential = f"{self.username}_{self.password}".encode()
175
+ return [
176
+ hashlib.sha256(credential).hexdigest(),
177
+ hashlib.md5(credential).hexdigest(),
178
+ ]
179
+
180
+ # --- Auth ---------------------------------------------------------------
181
+
182
+ def login(self) -> ResolveError | None:
183
+ """Authenticate and cache the session key. Returns None on success.
184
+
185
+ Tries each configured host in order. A host that can't be reached
186
+ (transport failure) advances to the next; a host that answers with an
187
+ authentication error stops the walk immediately, because the same
188
+ credentials would fail on every host of the same array.
189
+ """
190
+ last_transport_error = "unreachable"
191
+ for index in range(len(self.hosts)):
192
+ self._activate_host(index)
193
+ outcome = self._login_current_host()
194
+ if outcome is None:
195
+ return None # success — active host + session key are set
196
+ kind, message = outcome
197
+ if kind == "transport":
198
+ last_transport_error = message
199
+ continue # this host is down; try the next
200
+ # Definitive auth/response error — same creds fail on every host.
201
+ return ResolveError(
202
+ f"Login to MSA array '{self.name}' failed (tried SHA-256 and MD5): {message}"
203
+ )
204
+ hosts = ", ".join(self.hosts)
205
+ return ResolveError(
206
+ f"Could not reach MSA array '{self.name}' on any host ({hosts}): {last_transport_error}"
207
+ )
208
+
209
+ def _login_current_host(self) -> tuple[str, str] | None:
210
+ """Attempt login against the active host.
211
+
212
+ Returns None on success; ``("transport", msg)`` when the host is
213
+ unreachable; ``("auth", msg)`` when the host answered but rejected the
214
+ credentials (or returned an unparseable/again-erroring response).
215
+ """
216
+ http = self._http_client()
217
+ last_auth_error = "authentication failed"
218
+ for digest in self._login_hashes():
219
+ try:
220
+ resp = http.get(f"/login/{digest}")
221
+ except httpx.HTTPError as exc:
222
+ return ("transport", redact_exception(exc))
223
+ payload = self._parse_json(resp)
224
+ if isinstance(payload, ResolveError):
225
+ last_auth_error = payload.message
226
+ continue
227
+ status = _extract_status(payload)
228
+ if not _status_is_error(status):
229
+ key = status.get("response")
230
+ if isinstance(key, str) and key:
231
+ self._session_key = key
232
+ return None
233
+ last_auth_error = redact_secrets(str(status.get("response", "authentication failed")))
234
+ return ("auth", last_auth_error)
235
+
236
+ # --- Requests -----------------------------------------------------------
237
+
238
+ def request(self, segments: list[str]) -> dict[str, Any] | ResolveError:
239
+ """Run a ``GET /api/<segments...>`` call, logging in as needed.
240
+
241
+ ``segments`` are the already-validated path components after ``/api``
242
+ (e.g. ``["show", "disks"]``). On an expired session the client
243
+ re-logs-in once and retries; any second failure is returned as a
244
+ :class:`ResolveError`.
245
+
246
+ Returns the parsed response dict (envelope intact, including the
247
+ trailing ``status`` array) so the caller can inspect status before
248
+ trusting the payload.
249
+ """
250
+ if self._session_key is None:
251
+ err = self.login()
252
+ if err is not None:
253
+ return err
254
+
255
+ result = self._get(segments)
256
+ if isinstance(result, ResolveError):
257
+ return result
258
+ payload, http_status = result
259
+
260
+ status = _extract_status(payload)
261
+ if http_status == 401 or _status_is_expired(status):
262
+ # Session went stale — re-login once and retry.
263
+ self._session_key = None
264
+ err = self.login()
265
+ if err is not None:
266
+ return err
267
+ retry = self._get(segments)
268
+ if isinstance(retry, ResolveError):
269
+ return retry
270
+ payload, _ = retry
271
+
272
+ return payload
273
+
274
+ def _get(self, segments: list[str]) -> tuple[dict[str, Any], int] | ResolveError:
275
+ """Single GET with the session-key header. Returns (payload, http_status)."""
276
+ http = self._http_client()
277
+ path = "/" + "/".join(segments)
278
+ headers = {"sessionKey": self._session_key} if self._session_key else {}
279
+ try:
280
+ resp = http.get(path, headers=headers)
281
+ except httpx.HTTPError as exc:
282
+ return ResolveError(
283
+ f"Request to MSA array '{self.name}' failed: {redact_exception(exc)}"
284
+ )
285
+ payload = self._parse_json(resp)
286
+ if isinstance(payload, ResolveError):
287
+ return payload
288
+ return payload, resp.status_code
289
+
290
+ @staticmethod
291
+ def _parse_json(resp: httpx.Response) -> dict[str, Any] | ResolveError:
292
+ """Parse a response body as JSON, or return a redacted error."""
293
+ try:
294
+ data = resp.json()
295
+ except ValueError:
296
+ snippet = redact_secrets(resp.text[:200])
297
+ return ResolveError(
298
+ f"MSA returned a non-JSON response (HTTP {resp.status_code}): {snippet}"
299
+ )
300
+ if not isinstance(data, dict):
301
+ return ResolveError("MSA returned an unexpected (non-object) JSON response.")
302
+ return data
303
+
304
+ def close(self) -> None:
305
+ """Close the underlying HTTP client. Best-effort; used on shutdown/tests."""
306
+ if self._http is not None and not self._http.is_closed:
307
+ # Logout is best-effort — MSA idles the session out anyway.
308
+ with contextlib.suppress(httpx.HTTPError):
309
+ self._http.get("/exit", headers={"sessionKey": self._session_key or ""})
310
+ self._http.close()
311
+
312
+
313
+ # --- Client cache -----------------------------------------------------------
314
+
315
+ _CLIENTS: dict[str, MSAClient] = {}
316
+ """Cache of constructed clients keyed by array name. Lifetime = server process."""
317
+
318
+
319
+ def get_client(name: str | None, *, tls: TlsConfig) -> MSAClient | ResolveError:
320
+ """Return a cached, logged-in client for the named array.
321
+
322
+ Resolves the array from the registry (or the ``MSA_HOST`` env fallback),
323
+ constructs a client if one isn't cached, and logs it in. Returns
324
+ :class:`ResolveError` instead of raising so tools can surface a clean
325
+ message without try/except.
326
+ """
327
+ entry = resolve_array(name)
328
+ if entry is None:
329
+ if name is None:
330
+ from .config.arrays import is_multi_array
331
+
332
+ if is_multi_array():
333
+ return ResolveError(
334
+ "Multiple arrays are configured — specify which one with the "
335
+ "'array' parameter (use list_arrays to see configured targets)."
336
+ )
337
+ return ResolveError(
338
+ "No MSA array configured. Set MSA_HOST/MSA_USERNAME/MSA_PASSWORD or "
339
+ "create an arrays.json with at least one entry."
340
+ )
341
+ return ResolveError(
342
+ f"MSA array '{name}' not found. Use list_arrays to see configured targets."
343
+ )
344
+
345
+ cached = _CLIENTS.get(entry.name)
346
+ if cached is not None:
347
+ return cached
348
+
349
+ client = _build_client(entry, tls)
350
+ err = client.login()
351
+ if err is not None:
352
+ return err
353
+ _CLIENTS[entry.name] = client
354
+ return client
355
+
356
+
357
+ def _build_client(entry: ArrayEntry, tls: TlsConfig) -> MSAClient:
358
+ """Construct (without logging in) an MSAClient for a resolved entry."""
359
+ assert entry.password is not None # resolve_array only returns entries with a password
360
+ return MSAClient(
361
+ name=entry.name,
362
+ hosts=list(entry.hosts),
363
+ username=entry.username,
364
+ password=entry.password,
365
+ tls=tls,
366
+ )
367
+
368
+
369
+ def reset_client_cache() -> None:
370
+ """Drop all cached clients. Used by tests; rarely needed at runtime."""
371
+ for client in _CLIENTS.values():
372
+ client.close()
373
+ _CLIENTS.clear()
374
+
375
+
376
+ def probe_client(name: str | None, *, tls: TlsConfig) -> dict[str, Any]:
377
+ """Light-touch metadata for ``server_status``: confirms a target resolves.
378
+
379
+ Does NOT make a network call — that would slow startup and add a network
380
+ dependency to a status tool. Just confirms the config-side resolution
381
+ chain works.
382
+ """
383
+ entry = resolve_array(name)
384
+ if entry is None:
385
+ return {"resolved": False}
386
+ return {"resolved": True, "name": entry.name, "host": entry.host}
387
+
388
+
389
+ __all__ = [
390
+ "MSAClient",
391
+ "ResolveError",
392
+ "get_client",
393
+ "probe_client",
394
+ "reset_client_cache",
395
+ ]
File without changes