Files
AWatch-rus/detmir-mcp/main.py
T

433 lines
16 KiB
Python

#!/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://10.10.10.13:5600").rstrip("/")
WORKTIME_BASE = _env("DETMIR_WORKTIME_BASE", "http://10.10.10.13:5610").rstrip("/")
DLP_POLICY_BASE = _env("DETMIR_DLP_POLICY_BASE", "http://10.10.10.13:5601").rstrip("/")
DLP_CASE_BASE = _env("DETMIR_DLP_CASE_BASE", "http://10.10.10.13:5602").rstrip("/")
ONEC_BASE = _env("DETMIR_ONEC_BASE", "http://10.10.10.2:8710").rstrip("/")
GRAFANA_URL = _env("DETMIR_GRAFANA_URL", "http://10.10.10.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", "SHARKON2025")
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()