#!/usr/bin/env python3 from __future__ import annotations import json import os from base64 import b64encode from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib import parse, request from mcp.server.fastmcp import FastMCP def _env(name: str, default: str) -> str: value = os.environ.get(name) return value if value not in (None, "") else default def _env_int(name: str, default: int) -> int: raw = os.environ.get(name) if raw in (None, ""): return default return int(raw) PROJECT_ROOT = Path(__file__).resolve().parent.parent GRAFANA_DASHBOARDS_DIR = PROJECT_ROOT / "grafana" AW_BASE = _env("DETMIR_AW_BASE", "http://192.0.2.13:5600").rstrip("/") WORKTIME_BASE = _env("DETMIR_WORKTIME_BASE", "http://192.0.2.13:5610").rstrip("/") DLP_POLICY_BASE = _env("DETMIR_DLP_POLICY_BASE", "http://192.0.2.13:5601").rstrip("/") DLP_CASE_BASE = _env("DETMIR_DLP_CASE_BASE", "http://192.0.2.13:5602").rstrip("/") ONEC_BASE = _env("DETMIR_ONEC_BASE", "http://192.0.2.2:8710").rstrip("/") GRAFANA_URL = _env("DETMIR_GRAFANA_URL", "http://192.0.2.11:3000").rstrip("/") GRAFANA_USER = os.environ.get("DETMIR_GRAFANA_USER", "") GRAFANA_PASSWORD = os.environ.get("DETMIR_GRAFANA_PASSWORD", "") DEFAULT_HOST = _env("DETMIR_DEFAULT_HOST", "HOST-EXAMPLE") HTTP_TIMEOUT_SECONDS = _env_int("DETMIR_HTTP_TIMEOUT_SECONDS", 10) MCP_TRANSPORT = _env("DETMIR_MCP_TRANSPORT", "stdio") MCP_HOST = _env("DETMIR_MCP_HOST", "127.0.0.1") MCP_PORT = _env_int("DETMIR_MCP_PORT", 8765) MCP_PATH = _env("DETMIR_MCP_PATH", "/mcp") MCP_STATELESS_HTTP = _env("DETMIR_MCP_STATELESS_HTTP", "0").lower() in {"1", "true", "yes", "on"} mcp = FastMCP( "detmir", instructions="Read-only operational facade for DetMir ActivityWatch-Russian surfaces.", host=MCP_HOST, port=MCP_PORT, streamable_http_path=MCP_PATH, json_response=True, stateless_http=MCP_STATELESS_HTTP, ) def _http_json(url: str, timeout: int | None = None, headers: dict[str, str] | None = None) -> Any: req = request.Request(url, headers=headers or {}) with request.urlopen(req, timeout=timeout or HTTP_TIMEOUT_SECONDS) as resp: charset = resp.headers.get_content_charset() or "utf-8" body = resp.read().decode(charset) return json.loads(body) def _grafana_headers() -> dict[str, str]: if not GRAFANA_USER: return {} token = b64encode(f"{GRAFANA_USER}:{GRAFANA_PASSWORD}".encode("utf-8")).decode("ascii") return {"Authorization": f"Basic {token}"} def _parse_ts(value: str | None) -> datetime | None: if not value: return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) except ValueError: return None def _age_seconds(ts: datetime | None, now: datetime | None = None) -> int | None: if ts is None: return None ref = now or datetime.now(UTC) return max(0, int((ref - ts).total_seconds())) def _build_aw_api_base(base: str) -> str: if base.endswith("/api/0"): return base return base + "/api/0" def _safe_int(value: Any, default: int = 0) -> int: try: return int(value) except (TypeError, ValueError): return default def _safe_float(value: Any, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def _rows_sorted_by_active(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: return sorted(rows, key=lambda item: (_safe_int(item.get("active_seconds")), str(item.get("user_id") or "")), reverse=True) def _latest_bucket_timestamp(aw_api_base: str, bucket_id: str, meta: dict[str, Any]) -> datetime | None: metadata = meta.get("metadata") or {} return _parse_ts(metadata.get("end")) def _summarize_bucket_group( aw_api_base: str, buckets: dict[str, Any], prefix: str, *, host: str | None = None, max_age_seconds: int = 900, ) -> dict[str, Any]: matched = sorted( bucket_id for bucket_id in buckets if bucket_id.startswith(prefix) and (host is None or bucket_id.endswith(f"_{host}")) ) if not matched: return { "prefix": prefix, "host": host or "", "bucket_count": 0, "status": "missing", "max_age_seconds": max_age_seconds, "stale": [], } now = datetime.now(UTC) stale: list[dict[str, Any]] = [] freshest_age: int | None = None oldest_age: int | None = None for bucket_id in matched: ts = _latest_bucket_timestamp(aw_api_base, bucket_id, buckets.get(bucket_id, {})) age = _age_seconds(ts, now=now) if age is None: stale.append({"bucket_id": bucket_id, "age_seconds": None, "reason": "no_timestamp"}) continue freshest_age = age if freshest_age is None else min(freshest_age, age) oldest_age = age if oldest_age is None else max(oldest_age, age) if age > max_age_seconds: stale.append({"bucket_id": bucket_id, "age_seconds": age, "reason": "stale"}) status = "ok" if not stale else "degraded" return { "prefix": prefix, "host": host or "", "bucket_count": len(matched), "status": status, "max_age_seconds": max_age_seconds, "freshest_age_seconds": freshest_age, "oldest_age_seconds": oldest_age, "stale": stale[:10], } def _count_actions_by_kind(policy: dict[str, Any]) -> dict[str, int]: counts: dict[str, int] = {} def _push(action: Any) -> None: key = str(action or "unknown") counts[key] = counts.get(key, 0) + 1 for group in ("rules",): for rule in policy.get(group, []) or []: _push((rule or {}).get("action")) endpoint = policy.get("endpoint") or {} if isinstance(endpoint, dict): for rules in endpoint.values(): for rule in rules or []: _push((rule or {}).get("action")) return counts @mcp.tool() def aw_health_summary() -> dict[str, Any]: """Summarize core ActivityWatch health and bucket inventory.""" aw_api_base = _build_aw_api_base(AW_BASE) info = _http_json(f"{aw_api_base}/info") buckets = _http_json(f"{aw_api_base}/buckets") if not isinstance(buckets, dict): raise RuntimeError("AW bucket index is not a dict") prefixes = { "worktime_sessions": "aw-worktime-sessions_", "file_operations": "aw-file-operations_", "endpoint_signals": "aw-dlp-endpoint-signals_", "incidents": "aw-dlp-incidents_", "web_categories": "aw-detmir-web-category_", } bucket_summary = { name: sum(1 for bucket_id in buckets if bucket_id.startswith(prefix)) for name, prefix in prefixes.items() } return { "service": "activitywatch", "base_url": AW_BASE, "hostname": info.get("hostname"), "version": info.get("version"), "device_id": info.get("device_id"), "testing": bool(info.get("testing")), "bucket_count_total": len(buckets), "bucket_summary": bucket_summary, } @mcp.tool() def worktime_today(host: str = DEFAULT_HOST, day: str = "today", top_n: int = 5) -> dict[str, Any]: """Summarize current worktime rows for one host.""" params = {"host": host, "day": day} payload = _http_json(f"{WORKTIME_BASE}/reports/worktime/today?{parse.urlencode(params)}", timeout=max(HTTP_TIMEOUT_SECONDS, 30)) rows = payload.get("rows") or [] rows = rows if isinstance(rows, list) else [] ordered = _rows_sorted_by_active(rows) return { "service": "aw-worktime-api", "base_url": WORKTIME_BASE, "host": payload.get("host") or host, "report_date": payload.get("report_date"), "row_count": len(rows), "top_rows": [ { "user_id": row.get("user_id"), "user": row.get("user"), "active_seconds": row.get("active_seconds"), "active_hhmm": row.get("active_hhmm"), "sessions_count": row.get("sessions_count"), "samples_count": row.get("samples_count"), "first_activity": row.get("first_activity"), "last_activity": row.get("last_activity"), } for row in ordered[: max(1, top_n)] ], } @mcp.tool() def worktime_management( host: str = DEFAULT_HOST, day: str = "today", owner: str = "", department: str = "", top_n: int = 5, ) -> dict[str, Any]: """Summarize management worktime report and top actions.""" params = {"host": host, "day": day} if owner: params["owner"] = owner if department: params["department"] = department payload = _http_json(f"{WORKTIME_BASE}/reports/worktime/management?{parse.urlencode(params)}", timeout=max(HTTP_TIMEOUT_SECONDS, 30)) summary = payload.get("summary") or {} actions = payload.get("actions") or [] rows = payload.get("rows") or [] return { "service": "aw-worktime-api", "base_url": WORKTIME_BASE, "host": payload.get("host") or host, "report_date": payload.get("report_date"), "filters": payload.get("filters") or {}, "summary": { "users_count": summary.get("users_count"), "active_users": summary.get("active_users"), "inactive_users": summary.get("inactive_users"), "portfolio_coverage_pct": summary.get("portfolio_coverage_pct"), "actions_count": summary.get("actions_count"), "critical_actions_count": summary.get("critical_actions_count"), "high_actions_count": summary.get("high_actions_count"), "calendar_total_active_hhmm": summary.get("calendar_total_active_hhmm"), "workday_total_active_hhmm": summary.get("workday_total_active_hhmm"), }, "top_actions": [ { "action_id": action.get("action_id"), "priority": action.get("priority"), "owner": action.get("owner"), "user_id": action.get("user_id"), "reason": action.get("reason"), } for action in actions[: max(1, top_n)] ], "top_rows": [ { "user_id": row.get("user_id"), "user": row.get("user"), "manager_owner": row.get("manager_owner"), "department": row.get("department"), "active_hhmm": row.get("active_hhmm"), "coverage_pct": row.get("coverage_pct"), "status": row.get("status"), } for row in _rows_sorted_by_active(rows)[: max(1, top_n)] ], } @mcp.tool() def dlp_mode_get() -> dict[str, Any]: """Get current DLP mode and compact policy metadata.""" payload = _http_json(f"{DLP_POLICY_BASE}/api/0/dlp/policies/active") policy = payload.get("policy") or {} meta = policy.get("_tsj_meta") or {} return { "service": "aw-dlp-policy-engine", "base_url": DLP_POLICY_BASE, "active": bool(payload.get("active")), "policy_id": payload.get("policyId"), "name": payload.get("name"), "version": payload.get("version"), "checksum": payload.get("checksum"), "updated_at_utc": payload.get("updatedAtUtc"), "dlp_mode": meta.get("dlp_mode", "unknown"), "updated_by": meta.get("updated_by", ""), "ioc_enabled": bool((policy.get("ioc") or {}).get("enabled")), "web_rules_count": len(policy.get("rules") or []), "endpoint_rule_groups": sorted((policy.get("endpoint") or {}).keys()), "action_counts": _count_actions_by_kind(policy), } @mcp.tool() def dlp_health_summary(host: str = DEFAULT_HOST) -> dict[str, Any]: """Summarize DLP service health and bucket freshness over HTTP-only surfaces.""" aw_api_base = _build_aw_api_base(AW_BASE) aw_info = _http_json(f"{aw_api_base}/info") policy_health = _http_json(f"{DLP_POLICY_BASE}/healthz") case_health = _http_json(f"{DLP_CASE_BASE}/health") case_list = _http_json(f"{DLP_CASE_BASE}/api/0/dlp/cases?limit=200") buckets = _http_json(f"{aw_api_base}/buckets") if not isinstance(buckets, dict): raise RuntimeError("AW bucket index is not a dict") open_cases = 0 if isinstance(case_list, list): open_cases = sum(1 for item in case_list if str((item or {}).get("status") or "").lower() == "open") return { "host": host, "aw": { "hostname": aw_info.get("hostname"), "version": aw_info.get("version"), }, "policy_health": policy_health, "case_health": case_health, "open_cases_count": open_cases, "freshness": { "endpoint_signals": _summarize_bucket_group(aw_api_base, buckets, "aw-dlp-endpoint-signals_", host=host, max_age_seconds=900), "file_operations": _summarize_bucket_group(aw_api_base, buckets, "aw-file-operations_", host=host, max_age_seconds=900), "incidents": _summarize_bucket_group(aw_api_base, buckets, "aw-dlp-incidents_", host=host, max_age_seconds=86400), }, } @mcp.tool() def grafana_overview() -> dict[str, Any]: """Summarize Grafana health and version-controlled dashboard inventory.""" health = _http_json(f"{GRAFANA_URL}/api/health", headers=_grafana_headers()) dashboards = [] if GRAFANA_DASHBOARDS_DIR.exists(): dashboards = sorted(path.name for path in GRAFANA_DASHBOARDS_DIR.glob("*.json")) return { "service": "grafana", "base_url": GRAFANA_URL, "remote_health": health, "repo_dashboard_count": len(dashboards), "repo_dashboards": dashboards, } @mcp.tool() def onec_manager_brief() -> dict[str, Any]: """Summarize the latest 1C manager brief.""" payload = _http_json(f"{ONEC_BASE}/api/1/analytics-1c/manager/brief/latest", timeout=max(HTTP_TIMEOUT_SECONDS, 30)) context = payload.get("context") or {} portfolio = context.get("portfolio_summary") or {} freshness = context.get("freshness") or [] stale_sources = [ { "source": item.get("source"), "lag_hours": item.get("lag_hours"), "latest_ts": item.get("latest_ts"), } for item in freshness if bool((item or {}).get("stale")) ] brief = payload.get("brief") or {} top_risks = brief.get("top_risks") or context.get("top_risks") or [] actions = brief.get("actions") or [] return { "service": "company-intelligence-api", "base_url": ONEC_BASE, "generated_at": payload.get("generated_at"), "render_mode": payload.get("render_mode"), "model": payload.get("model"), "portfolio_summary": { "companies_total": portfolio.get("companies_total"), "critical_total": portfolio.get("critical_total"), "busy_total": portfolio.get("busy_total"), "open_cases_total": portfolio.get("open_cases_total"), "detections_total": portfolio.get("detections_total"), "activity_30d_total": portfolio.get("activity_30d_total"), "activity_forecast_30d_total": portfolio.get("activity_forecast_30d_total"), }, "stale_sources": stale_sources[:10], "top_risks": [ { "company": item.get("company") or item.get("counterparty") or item.get("infobase"), "severity": item.get("severity") or item.get("signal_severity"), "reason": item.get("reason") or item.get("top_signal"), "recommended_action": item.get("recommended_action"), } for item in top_risks[:5] ], "actions": actions[:5], } def main() -> None: mcp.run(transport=MCP_TRANSPORT) if __name__ == "__main__": main()