mcp-server-malcolm 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.
@@ -0,0 +1,26 @@
1
+ """MCP server for Malcolm network traffic analysis platform.
2
+
3
+ Provides tool access to Malcolm's unified API, including:
4
+ - Network traffic search and aggregation
5
+ - Field discovery and validation
6
+ - Suricata alert queries
7
+ - Arkime session search and PCAP download
8
+ - NetBox asset lookup
9
+ - System health and data coverage
10
+
11
+ Works with any MCP-compatible agent.
12
+ """
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ from mcp_server_malcolm.client import MalcolmClient
17
+
18
+ __all__ = ["MalcolmClient", "__version__"]
19
+
20
+
21
+ def main() -> None:
22
+ """Entry point for the MCP server."""
23
+ from mcp_server_malcolm.server import create_server
24
+
25
+ mcp = create_server()
26
+ mcp.run()
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m mcp_server_malcolm"""
2
+
3
+ from mcp_server_malcolm import main
4
+
5
+ main()
@@ -0,0 +1,60 @@
1
+ """Structured write-audit sink.
2
+
3
+ One line of JSON per write attempt. Destination is stderr by default, or a
4
+ file when MALCOLM_MCP_AUDIT_FILE is set (opened+closed per write — no
5
+ long-lived handle, safe under crashes). Read tools are never audited.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from datetime import datetime, timezone
13
+ from typing import Any
14
+
15
+ _MAX_VALUE_LEN = 200
16
+
17
+
18
+ def outcome_for_status(status_code: int) -> str:
19
+ """Map an HTTP status code to an audit outcome token."""
20
+ if 200 <= status_code < 300:
21
+ return "ok"
22
+ if 400 <= status_code < 500:
23
+ return "http_4xx"
24
+ if 500 <= status_code < 600:
25
+ return "http_5xx"
26
+ return "http_other"
27
+
28
+
29
+ def _truncate(value: Any) -> Any:
30
+ if isinstance(value, str) and len(value) > _MAX_VALUE_LEN:
31
+ return value[:_MAX_VALUE_LEN] + "…"
32
+ return value
33
+
34
+
35
+ def record(
36
+ tool: str,
37
+ cls: str,
38
+ target: str,
39
+ params_summary: dict[str, Any],
40
+ outcome: str,
41
+ audit_file: str | None = None,
42
+ ) -> None:
43
+ """Emit one audit line. Never raises — auditing must not break a tool."""
44
+ row = {
45
+ "ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
46
+ "tool": tool,
47
+ "class": cls,
48
+ "target": _truncate(target),
49
+ "params": {k: _truncate(v) for k, v in params_summary.items()},
50
+ "outcome": outcome,
51
+ }
52
+ line = json.dumps(row, ensure_ascii=False, default=str)
53
+ try:
54
+ if audit_file:
55
+ with open(audit_file, "a", encoding="utf-8") as fh:
56
+ fh.write(line + "\n")
57
+ else:
58
+ print(line, file=sys.stderr, flush=True)
59
+ except Exception: # noqa: BLE001 — auditing is best-effort, never fatal
60
+ pass
@@ -0,0 +1,581 @@
1
+ """Malcolm HTTP client -- core reusable component.
2
+
3
+ All Malcolm API interactions go through this client.
4
+ Usable standalone (direct import) or via the MCP server layer.
5
+
6
+ Configuration via environment variables:
7
+ MALCOLM_URL Base URL (default: https://localhost)
8
+ MALCOLM_USERNAME Basic auth user (default: admin)
9
+ MALCOLM_PASSWORD Basic auth password (default: admin)
10
+ MALCOLM_SSL_VERIFY Verify TLS certs (default: false)
11
+ MALCOLM_TIMEOUT Request timeout seconds (default: 30)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ from difflib import get_close_matches
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _parse_ssl_verify(raw: str) -> bool | str:
28
+ """Parse MALCOLM_SSL_VERIFY: "true"/"false" → bool, anything else → CA path."""
29
+ val = raw.strip()
30
+ low = val.lower()
31
+ if low == "true":
32
+ return True
33
+ if low == "false" or not val:
34
+ return False
35
+ return val # treat as a CA-bundle path for httpx verify=
36
+
37
+
38
+ class MalcolmClient:
39
+ """Async HTTP client for the Malcolm REST API.
40
+
41
+ Wraps /mapi/* endpoints (unified gateway) and /arkime/api/* endpoints.
42
+ Handles authentication, SSL, timeouts, and field caching.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ base_url: str = "https://localhost",
48
+ username: str = "admin",
49
+ password: str = "admin",
50
+ ssl_verify: bool | str = False,
51
+ timeout: float = 30.0,
52
+ ) -> None:
53
+ self._base_url = base_url.rstrip("/")
54
+ self._auth = httpx.BasicAuth(username, password)
55
+ self._ssl_verify = ssl_verify
56
+ self._timeout = timeout
57
+ self._http: httpx.AsyncClient | None = None
58
+ self._field_cache: dict[str, str] | None = None
59
+
60
+ @property
61
+ def base_url(self) -> str:
62
+ """The configured Malcolm base URL (trailing slash stripped)."""
63
+ return self._base_url
64
+
65
+ @classmethod
66
+ def from_env(cls) -> MalcolmClient:
67
+ """Create client from environment variables.
68
+
69
+ MALCOLM_SSL_VERIFY accepts "true"/"false" (case-insensitive) or a path
70
+ to a CA bundle — anything that is not "true"/"false" is passed through
71
+ to httpx's verify= as a CA path, so verification is never silently
72
+ disabled when an operator supplies a real bundle.
73
+ """
74
+ return cls(
75
+ base_url=os.environ.get("MALCOLM_URL", "https://localhost"),
76
+ username=os.environ.get("MALCOLM_USERNAME", "admin"),
77
+ password=os.environ.get("MALCOLM_PASSWORD", "admin"),
78
+ ssl_verify=_parse_ssl_verify(os.environ.get("MALCOLM_SSL_VERIFY", "false")),
79
+ timeout=float(os.environ.get("MALCOLM_TIMEOUT", "30")),
80
+ )
81
+
82
+ # -- HTTP primitives ------------------------------------------------
83
+
84
+ async def _client(self) -> httpx.AsyncClient:
85
+ if self._http is None or self._http.is_closed:
86
+ self._http = httpx.AsyncClient(
87
+ base_url=self._base_url,
88
+ auth=self._auth,
89
+ verify=self._ssl_verify,
90
+ # Short connect budget: a dead/unreachable host must fail in
91
+ # seconds, not hang the full read timeout on every call.
92
+ timeout=httpx.Timeout(self._timeout, connect=5.0),
93
+ follow_redirects=True,
94
+ )
95
+ return self._http
96
+
97
+ async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
98
+ """HTTP GET, returns parsed JSON."""
99
+ c = await self._client()
100
+ resp = await c.get(path, params=params)
101
+ resp.raise_for_status()
102
+ return resp.json()
103
+
104
+ async def post(self, path: str, body: dict[str, Any] | None = None) -> Any:
105
+ """HTTP POST with JSON body, returns parsed JSON."""
106
+ c = await self._client()
107
+ resp = await c.post(path, json=body or {})
108
+ resp.raise_for_status()
109
+ return resp.json()
110
+
111
+ async def get_raw(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
112
+ """HTTP GET returning the raw response (for binary downloads)."""
113
+ c = await self._client()
114
+ return await c.get(path, params=params)
115
+
116
+ async def close(self) -> None:
117
+ if self._http is not None and not self._http.is_closed:
118
+ await self._http.aclose()
119
+ self._http = None
120
+
121
+ # -- Search & Aggregation -------------------------------------------
122
+
123
+ async def search(
124
+ self,
125
+ filters: dict[str, Any] | None = None,
126
+ limit: int = 20,
127
+ time_from: str = "",
128
+ time_to: str = "",
129
+ ) -> dict[str, Any]:
130
+ """Search indexed documents via POST /mapi/document."""
131
+ body: dict[str, Any] = {"limit": limit}
132
+ if filters:
133
+ body["filter"] = filters
134
+ if time_from:
135
+ body["from"] = time_from
136
+ if time_to:
137
+ body["to"] = time_to
138
+ return await self.post("/mapi/document", body)
139
+
140
+ async def aggregate(
141
+ self,
142
+ fields: str,
143
+ filters: dict[str, Any] | None = None,
144
+ limit: int = 500,
145
+ time_from: str = "",
146
+ time_to: str = "",
147
+ ) -> dict[str, Any]:
148
+ """Aggregate on one or more fields via POST /mapi/agg/<fields>.
149
+
150
+ Args:
151
+ fields: Comma-separated field names, e.g. "source.ip,destination.ip".
152
+ """
153
+ body: dict[str, Any] = {"limit": limit}
154
+ if filters:
155
+ body["filter"] = filters
156
+ if time_from:
157
+ body["from"] = time_from
158
+ if time_to:
159
+ body["to"] = time_to
160
+ return await self.post(f"/mapi/agg/{fields}", body)
161
+
162
+ # -- OpenSearch DSL (generic; backend-agnostic) ---------------------
163
+ # These speak plain OpenSearch DSL against the configured endpoint via
164
+ # Malcolm's /mapi/opensearch proxy. No Malcolm-specific query shape —
165
+ # point the base_url elsewhere and they work against any OpenSearch.
166
+
167
+ async def opensearch_dsl(self, index: str, body: dict[str, Any]) -> dict[str, Any]:
168
+ """POST a raw DSL search body; returns the raw OpenSearch response."""
169
+ return await self.post(f"/mapi/opensearch/{index}/_search", body)
170
+
171
+ async def opensearch_count(self, index: str, query: dict[str, Any]) -> dict[str, Any]:
172
+ """Count docs matching a DSL query clause."""
173
+ return await self.post(f"/mapi/opensearch/{index}/_count", {"query": query})
174
+
175
+ async def opensearch_indices(self, pattern: str = "*") -> Any:
176
+ """List indices (name/health/status/docs.count) as JSON."""
177
+ return await self.get(
178
+ f"/mapi/opensearch/_cat/indices/{pattern}",
179
+ params={"format": "json", "h": "index,health,status,docs.count"},
180
+ )
181
+
182
+ async def opensearch_mapping(self, index: str) -> dict[str, Any]:
183
+ """Field mapping for an index."""
184
+ return await self.get(f"/mapi/opensearch/{index}/_mapping")
185
+
186
+ async def opensearch_cluster_health(self) -> dict[str, Any]:
187
+ """Cluster health document."""
188
+ return await self.get("/mapi/opensearch/_cluster/health")
189
+
190
+ # -- Fields ---------------------------------------------------------
191
+
192
+ async def get_fields(self) -> dict[str, str]:
193
+ """Return {field_name: field_type} from /mapi/fields (cached)."""
194
+ if self._field_cache is not None:
195
+ return self._field_cache
196
+
197
+ data = await self.get("/mapi/fields")
198
+ fields_raw = data.get("fields", {})
199
+ self._field_cache = {
200
+ name: info.get("type", "unknown") if isinstance(info, dict) else "unknown"
201
+ for name, info in fields_raw.items()
202
+ }
203
+ logger.info("[malcolm] Cached %d fields", len(self._field_cache))
204
+ return self._field_cache
205
+
206
+ def invalidate_field_cache(self) -> None:
207
+ """Force re-fetch of field list on next call."""
208
+ self._field_cache = None
209
+
210
+ async def search_fields(
211
+ self,
212
+ keyword: str = "",
213
+ prefix: str = "",
214
+ field_type: str = "",
215
+ ) -> list[tuple[str, str]]:
216
+ """Search fields by keyword, prefix, or type. Returns [(name, type)]."""
217
+ fields = await self.get_fields()
218
+ results: list[tuple[str, str]] = []
219
+
220
+ for name, ftype in sorted(fields.items()):
221
+ if prefix and not name.startswith(prefix):
222
+ continue
223
+ if field_type and ftype != field_type:
224
+ continue
225
+ if keyword and keyword.lower() not in name.lower():
226
+ continue
227
+ results.append((name, ftype))
228
+
229
+ return results
230
+
231
+ async def resolve_field(self, name: str, max_suggestions: int = 5) -> dict[str, Any]:
232
+ """Check if a field exists; if not, suggest alternatives."""
233
+ fields = await self.get_fields()
234
+
235
+ if name in fields:
236
+ return {"exists": True, "field": name, "type": fields[name]}
237
+
238
+ all_names = list(fields.keys())
239
+
240
+ # Normalized match (ignore underscores/hyphens/dots)
241
+ norm = name.lower().replace("_", "").replace("-", "").replace(".", "")
242
+ for f in all_names:
243
+ f_norm = f.lower().replace("_", "").replace("-", "").replace(".", "")
244
+ if norm == f_norm:
245
+ return {"exists": False, "field": name, "suggestion": f, "type": fields[f]}
246
+
247
+ # Fuzzy match
248
+ similar = get_close_matches(name, all_names, n=max_suggestions, cutoff=0.5)
249
+
250
+ # Substring match
251
+ lower = name.lower()
252
+ for f in all_names:
253
+ if lower in f.lower() and f not in similar:
254
+ similar.append(f)
255
+ if len(similar) >= max_suggestions:
256
+ break
257
+
258
+ suggestions = {s: fields[s] for s in similar}
259
+ return {"exists": False, "field": name, "suggestions": suggestions}
260
+
261
+ # -- Health & Status ------------------------------------------------
262
+
263
+ async def ping(self) -> dict[str, Any]:
264
+ return await self.get("/mapi/ping")
265
+
266
+ async def ready(self) -> dict[str, Any]:
267
+ return await self.get("/mapi/ready")
268
+
269
+ async def version(self) -> dict[str, Any]:
270
+ return await self.get("/mapi/version")
271
+
272
+ async def ingest_stats(self) -> dict[str, Any]:
273
+ return await self.get("/mapi/ingest-stats")
274
+
275
+ # -- Indices --------------------------------------------------------
276
+
277
+ async def indices(self) -> dict[str, Any]:
278
+ return await self.get("/mapi/indices")
279
+
280
+ # -- Dashboard Export -----------------------------------------------
281
+
282
+ async def dashboard_export(self, dashboard_id: str) -> dict[str, Any]:
283
+ return await self.get(f"/mapi/dashboard-export/{dashboard_id}")
284
+
285
+ # -- NetBox (forwarded) ---------------------------------------------
286
+
287
+ async def netbox_get(self, path: str, params: dict[str, Any] | None = None) -> Any:
288
+ """Query NetBox API via Malcolm's /mapi/netbox/ proxy."""
289
+ return await self.get(f"/mapi/netbox/{path.lstrip('/')}", params=params)
290
+
291
+ async def netbox_sites(self) -> dict[str, Any]:
292
+ """Site directory from /mapi/netbox-sites (ids + metadata)."""
293
+ return await self.get("/mapi/netbox-sites")
294
+
295
+ # -- Arkime (forwarded) ---------------------------------------------
296
+
297
+ async def arkime_sessions(
298
+ self,
299
+ expression: str,
300
+ limit: int = 10,
301
+ order: str = "lastPacket:desc",
302
+ time_from: str = "",
303
+ time_to: str = "",
304
+ ) -> dict[str, Any]:
305
+ """Search Arkime sessions. Omitting the range uses Arkime's default
306
+ (recent) window; pass epoch-seconds strings in time_from/time_to
307
+ (as startTime/stopTime) to reach historical data."""
308
+ params: dict[str, Any] = {"expression": expression, "length": limit, "order": order}
309
+ if time_from:
310
+ params["startTime"] = time_from
311
+ if time_to:
312
+ params["stopTime"] = time_to
313
+ return await self.get("/arkime/api/sessions", params=params)
314
+
315
+ async def arkime_session_pcap(self, session_id: str) -> bytes:
316
+ """Download PCAP bytes for a single Arkime session.
317
+
318
+ Uses GET /arkime/api/sessions.pcap?ids=<id> (the id from
319
+ arkime_sessions, e.g. "3@240425-..."; the node prefix is optional).
320
+ Verified live against Malcolm 25.12.1 — the expression=id==<id> form
321
+ returns 404 "no sessions found", and there is no
322
+ /arkime/api/session/<id>/pcap route.
323
+ """
324
+ resp = await self.get_raw(
325
+ "/arkime/api/sessions.pcap",
326
+ params={"ids": session_id},
327
+ )
328
+ resp.raise_for_status()
329
+ return resp.content
330
+
331
+ async def arkime_hunts(self, length: int = 50, history: bool = False) -> dict[str, Any]:
332
+ """List Arkime hunt jobs (READ). Ships with the hunt-job write class."""
333
+ params = {"length": length, "history": "true" if history else "false"}
334
+ return await self.get("/arkime/api/hunts", params=params)
335
+
336
+ async def arkime_session_detail(self, session_id: str) -> dict[str, Any]:
337
+ """All fields for one session via GET /arkime/api/session/<id>.
338
+
339
+ The list search (arkime_sessions) returns a trimmed view; this returns
340
+ the full SPI document for a single session id.
341
+ """
342
+ return await self.get(f"/arkime/api/session/{session_id}")
343
+
344
+ async def arkime_unique(
345
+ self,
346
+ expression: str,
347
+ field: str,
348
+ counts: bool = True,
349
+ ) -> str:
350
+ """Distinct values of one field via GET /arkime/api/unique.
351
+
352
+ Returns text (one value per line), not JSON — this Arkime endpoint
353
+ streams a plain-text body, optionally suffixed with counts.
354
+ """
355
+ params: dict[str, Any] = {
356
+ "exp": field,
357
+ "counts": 1 if counts else 0,
358
+ }
359
+ if expression:
360
+ params["expression"] = expression
361
+ resp = await self.get_raw("/arkime/api/unique", params=params)
362
+ resp.raise_for_status()
363
+ return resp.text
364
+
365
+ @staticmethod
366
+ def _arkime_query(expression: str, time_from: str, time_to: str) -> dict[str, Any]:
367
+ """Standard Arkime SessionsQuery params (expression + time window)."""
368
+ params: dict[str, Any] = {}
369
+ if expression:
370
+ params["expression"] = expression
371
+ if time_from:
372
+ params["startTime"] = time_from
373
+ if time_to:
374
+ params["stopTime"] = time_to
375
+ return params
376
+
377
+ async def arkime_spigraph(
378
+ self,
379
+ field: str,
380
+ expression: str = "",
381
+ size: int = 20,
382
+ time_from: str = "",
383
+ time_to: str = "",
384
+ ) -> dict[str, Any]:
385
+ """Top values of one field with a time graph via GET /api/spigraph."""
386
+ params = self._arkime_query(expression, time_from, time_to)
387
+ params["field"] = field
388
+ params["size"] = size
389
+ return await self.get("/arkime/api/spigraph", params=params)
390
+
391
+ async def arkime_spiview(
392
+ self,
393
+ spi: str,
394
+ expression: str = "",
395
+ time_from: str = "",
396
+ time_to: str = "",
397
+ ) -> dict[str, Any]:
398
+ """Field-value profile across fields via GET /api/spiview.
399
+
400
+ Args:
401
+ spi: Comma-separated db fields, each optionally ":<count>", e.g.
402
+ "protocols:10,ip.dst:20".
403
+ """
404
+ params = self._arkime_query(expression, time_from, time_to)
405
+ params["spi"] = spi
406
+ return await self.get("/arkime/api/spiview", params=params)
407
+
408
+ async def arkime_connections(
409
+ self,
410
+ src_field: str = "ip.src",
411
+ dst_field: str = "ip.dst:port",
412
+ expression: str = "",
413
+ time_from: str = "",
414
+ time_to: str = "",
415
+ ) -> dict[str, Any]:
416
+ """Source/destination connection graph via GET /api/connections.
417
+
418
+ Returns {"nodes": [...], "links": [...]} for tracing who talked to whom.
419
+ """
420
+ params = self._arkime_query(expression, time_from, time_to)
421
+ params["srcField"] = src_field
422
+ params["dstField"] = dst_field
423
+ return await self.get("/arkime/api/connections", params=params)
424
+
425
+ # -- Write primitives (gated) ---------------------------------------
426
+ # Every method here issues a mutating request. By convention they are
427
+ # named _write_* and imported ONLY from tools/write/*.py — a seam test
428
+ # asserts no other module references them. Do not call these from a
429
+ # read tool.
430
+
431
+ async def _write_event(self, alert: dict[str, Any]) -> dict[str, Any]:
432
+ """POST /mapi/event — index an external alert as a session document.
433
+
434
+ Malcolm's own purpose-built write endpoint (26.06.1). Wraps the caller
435
+ payload as {"alert": alert}; Malcolm deep-merges alert["body"] into an
436
+ ECS-ish doc and indexes it into arkime_sessions3-<yymmdd>.
437
+ """
438
+ return await self.post("/mapi/event", {"alert": alert})
439
+
440
+ async def _write_arkime_tags(self, ids: str, tags: str, segments: str = "no") -> dict[str, Any]:
441
+ """POST /arkime/api/sessions/addtags — additive tagging (Arkime v6.5.0).
442
+
443
+ checkHeaderToken passes with no token when the request has no
444
+ cookie/referer. But a prior hunt-prime may have left an ARKIME-COOKIE
445
+ in the shared jar; httpx would then send it as a Cookie header, which
446
+ flips Arkime to checkCookieToken. So if a cookie is present, replay it
447
+ as x-arkime-cookie too (same first-party token, same Basic-auth user)
448
+ to stay consistent. Tags are sanitized to [-a-zA-Z0-9_:,] server-side.
449
+ """
450
+ c = await self._client()
451
+ token = c.cookies.get("ARKIME-COOKIE")
452
+ headers = {"x-arkime-cookie": token} if token else {}
453
+ resp = await c.post(
454
+ "/arkime/api/sessions/addtags",
455
+ json={"ids": ids, "tags": tags, "segments": segments},
456
+ headers=headers,
457
+ )
458
+ resp.raise_for_status()
459
+ return resp.json()
460
+
461
+ async def _write_arkime_hunt(self, hunt: dict[str, Any]) -> dict[str, Any]:
462
+ """POST /arkime/api/hunt — create a cross-PCAP packet-search job.
463
+
464
+ Guarded by checkCookieToken (Arkime v6.5.0), so we first GET
465
+ /arkime/api/hunts (the setCookie middleware issues an ARKIME-COOKIE),
466
+ then replay that cookie as the x-arkime-cookie header on the POST. The
467
+ userId in the token matches because both requests carry the same Basic
468
+ auth → same X-Forwarded-User.
469
+ """
470
+ c = await self._client()
471
+ await c.get("/arkime/api/hunts", params={"length": 1})
472
+ token = c.cookies.get("ARKIME-COOKIE")
473
+ headers = {"x-arkime-cookie": token} if token else {}
474
+ resp = await c.post("/arkime/api/hunt", json=hunt, headers=headers)
475
+ resp.raise_for_status()
476
+ return resp.json()
477
+
478
+ async def _write_upload_pcap(
479
+ self, filename: str, content: bytes, tags: str = ""
480
+ ) -> httpx.Response:
481
+ """POST /server/php/submit.php — FilePond multipart PCAP upload.
482
+
483
+ FilePond field name is 'filepond' (config.php ENTRY_FIELD). Returns the
484
+ raw response so the caller can inspect status/text (FilePond replies 200
485
+ with an empty/transfer-id body on success). A downstream libmagic check
486
+ (pcap-monitor) does the real type enforcement. Verified live against
487
+ Malcolm 25.12.1 — the bare /upload path is a rewrite target and 405s on
488
+ a direct POST; the FilePond processor is under /server/php/submit.php.
489
+ """
490
+ c = await self._client()
491
+ files = {"filepond": (filename, content, "application/octet-stream")}
492
+ data = {"tags": tags} if tags else None
493
+ return await c.post("/server/php/submit.php", files=files, data=data)
494
+
495
+ # -- Convenience helpers --------------------------------------------
496
+
497
+ async def field_values(
498
+ self,
499
+ field: str,
500
+ limit: int = 30,
501
+ filters: dict[str, Any] | None = None,
502
+ time_from: str = "",
503
+ time_to: str = "",
504
+ ) -> list[dict[str, Any]]:
505
+ """Get distinct values for a field via aggregation.
506
+
507
+ Returns list of {"key": ..., "doc_count": ...}.
508
+ """
509
+ data = await self.aggregate(
510
+ fields=field,
511
+ filters=filters,
512
+ limit=limit,
513
+ time_from=time_from,
514
+ time_to=time_to,
515
+ )
516
+ return _extract_buckets(data, field)
517
+
518
+ async def field_profile(
519
+ self, field: str, time_from: str = "", time_to: str = ""
520
+ ) -> list[dict[str, Any]]:
521
+ """Show which datasets contain a given field, over a time range.
522
+
523
+ Omitting the range uses Malcolm's default (recent) window — pass
524
+ time_from/time_to (dateparser format) to reach historical data.
525
+
526
+ Returns list of {"dataset": ..., "doc_count": ...}.
527
+ """
528
+ data = await self.aggregate(
529
+ fields="event.dataset",
530
+ filters={f"!{field}": None},
531
+ time_from=time_from,
532
+ time_to=time_to,
533
+ )
534
+ buckets = _extract_buckets(data, "event.dataset")
535
+ return [{"dataset": b["key"], "doc_count": b["doc_count"]} for b in buckets]
536
+
537
+
538
+ def _extract_buckets(data: dict[str, Any], field: str) -> list[dict[str, Any]]:
539
+ """Extract aggregation buckets from Malcolm /mapi/agg response."""
540
+ # Malcolm agg response nests buckets under the field name (dots replaced)
541
+ # Try several extraction strategies
542
+ if "results" in data:
543
+ results = data["results"]
544
+ if isinstance(results, list):
545
+ return results
546
+ if isinstance(results, dict):
547
+ # Nested agg: look for the first key with buckets
548
+ for val in results.values():
549
+ if isinstance(val, dict) and "buckets" in val:
550
+ return val["buckets"]
551
+ if isinstance(val, list):
552
+ return val
553
+
554
+ # Flat bucket list at top level
555
+ if "buckets" in data:
556
+ return data["buckets"]
557
+
558
+ # Field-keyed access. Malcolm /mapi/agg keys the agg by the literal
559
+ # field name WITH dots (e.g. {"event.dataset": {"buckets": [...]}});
560
+ # keep the underscore variant as a fallback for older responses.
561
+ for field_key in (field, field.replace(".", "_")):
562
+ if field_key in data:
563
+ sub = data[field_key]
564
+ if isinstance(sub, dict) and "buckets" in sub:
565
+ return sub["buckets"]
566
+ if isinstance(sub, list):
567
+ return sub
568
+
569
+ # Last resort: if response has 'values' key
570
+ if "values" in data:
571
+ vals = data["values"]
572
+ if isinstance(vals, list):
573
+ return vals
574
+
575
+ logger.warning("[malcolm] Could not extract buckets from agg response for field=%s", field)
576
+ return []
577
+
578
+
579
+ def _format_json(data: Any, indent: int = 2) -> str:
580
+ """Format data as JSON string for MCP tool output."""
581
+ return json.dumps(data, indent=indent, ensure_ascii=False, default=str)