diff --git a/web/mcp_env_catalog.py b/web/mcp_env_catalog.py new file mode 100644 index 0000000..0de73f1 --- /dev/null +++ b/web/mcp_env_catalog.py @@ -0,0 +1,201 @@ +# web/mcp_env_catalog.py +"""Static catalog of MCP server env vars exposed in the settings UI. + +Whitelist policy: keys not in this catalog are not displayed or editable. +""" + +from __future__ import annotations + +from dataclasses import dataclass, asdict +from typing import Any, Literal + +EnvType = Literal["bool", "int", "float", "string"] +EnvCategory = Literal[ + "multimodal", "rerank", "halacha", "credentials", "connection", "general" +] + + +@dataclass(frozen=True) +class EnvSpec: + key: str + category: EnvCategory + type: EnvType + description: str + is_secret: bool + is_editable: bool + default: Any = None + min: float | None = None + max: float | None = None + enum_values: list[str] | None = None + + def to_public_dict(self) -> dict[str, Any]: + d = asdict(self) + return d + + +ENV_CATALOG: dict[str, EnvSpec] = { + # ── multimodal ───────────────────────────────────────────────── + "MULTIMODAL_ENABLED": EnvSpec( + "MULTIMODAL_ENABLED", "multimodal", "bool", + "הפעלת page-image embeddings (voyage-multimodal-3)", + is_secret=False, is_editable=True, default=False, + ), + "MULTIMODAL_MODEL": EnvSpec( + "MULTIMODAL_MODEL", "multimodal", "string", + "מודל multimodal של Voyage", + is_secret=False, is_editable=True, default="voyage-multimodal-3", + ), + "MULTIMODAL_DPI": EnvSpec( + "MULTIMODAL_DPI", "multimodal", "int", + "DPI ל-rendering של עמוד למודל", + is_secret=False, is_editable=True, default=144, min=72, max=300, + ), + "MULTIMODAL_THUMB_DPI": EnvSpec( + "MULTIMODAL_THUMB_DPI", "multimodal", "int", + "DPI ל-thumbnail בתצוגה", + is_secret=False, is_editable=True, default=96, min=72, max=200, + ), + "MULTIMODAL_TEXT_WEIGHT": EnvSpec( + "MULTIMODAL_TEXT_WEIGHT", "multimodal", "float", + "משקל text vs image ב-RRF (0=image בלבד, 1=text בלבד)", + is_secret=False, is_editable=True, default=0.5, min=0.0, max=1.0, + ), + "MULTIMODAL_RRF_K": EnvSpec( + "MULTIMODAL_RRF_K", "multimodal", "int", + "RRF damping constant", + is_secret=False, is_editable=True, default=60, min=1, max=200, + ), + # ── rerank ───────────────────────────────────────────────────── + "VOYAGE_RERANK_ENABLED": EnvSpec( + "VOYAGE_RERANK_ENABLED", "rerank", "bool", + "הפעלת cross-encoder rerank", + is_secret=False, is_editable=True, default=False, + ), + "VOYAGE_RERANK_MODEL": EnvSpec( + "VOYAGE_RERANK_MODEL", "rerank", "string", + "מודל rerank", + is_secret=False, is_editable=True, default="rerank-2", + ), + "VOYAGE_RERANK_FETCH_K": EnvSpec( + "VOYAGE_RERANK_FETCH_K", "rerank", "int", + "מספר candidates לפני rerank", + is_secret=False, is_editable=True, default=50, min=10, max=200, + ), + # ── halacha ──────────────────────────────────────────────────── + "HALACHA_AUTO_APPROVE_THRESHOLD": EnvSpec( + "HALACHA_AUTO_APPROVE_THRESHOLD", "halacha", "float", + "סף confidence ל-auto-approve של הלכות שחולצו", + is_secret=False, is_editable=True, default=0.80, min=0.0, max=1.0, + ), + # ── general ──────────────────────────────────────────────────── + "VOYAGE_MODEL": EnvSpec( + "VOYAGE_MODEL", "general", "string", + "מודל embedding ראשי", + is_secret=False, is_editable=True, default="voyage-law-2", + ), + "AUDIT_ENABLED": EnvSpec( + "AUDIT_ENABLED", "general", "bool", + "הפעלת audit log", + is_secret=False, is_editable=True, default=True, + ), + # ── credentials (read-only, masked) ──────────────────────────── + "VOYAGE_API_KEY": EnvSpec( + "VOYAGE_API_KEY", "credentials", "string", + "Voyage AI API key", + is_secret=True, is_editable=False, + ), + "GOOGLE_CLOUD_VISION_API_KEY": EnvSpec( + "GOOGLE_CLOUD_VISION_API_KEY", "credentials", "string", + "Google Cloud Vision API key (OCR)", + is_secret=True, is_editable=False, + ), + "INFISICAL_TOKEN": EnvSpec( + "INFISICAL_TOKEN", "credentials", "string", + "Infisical SDK token", + is_secret=True, is_editable=False, + ), + # ── connection (read-only — שינוי runtime מסוכן) ────────────── + "POSTGRES_URL": EnvSpec( + "POSTGRES_URL", "connection", "string", + "PostgreSQL connection URL", + is_secret=True, is_editable=False, + ), + "REDIS_URL": EnvSpec( + "REDIS_URL", "connection", "string", + "Redis connection URL", + is_secret=False, is_editable=False, + ), + "DATA_DIR": EnvSpec( + "DATA_DIR", "connection", "string", + "Data directory path", + is_secret=False, is_editable=False, + ), +} + + +# ── helpers ──────────────────────────────────────────────────────── + + +def mask_secret(value: str | None) -> str: + """Mask a secret to **** + last 4 chars (or **** if shorter).""" + if value is None: + return "" + if len(value) <= 4: + return "****" + return "****" + value[-4:] + + +def coerce(spec: EnvSpec, raw: Any) -> Any: + """Coerce raw input (str from JSON) to typed value, with validation. + + Raises ValueError on invalid input. + """ + if raw is None or raw == "": + raise ValueError("ערך ריק") + if spec.type == "bool": + if isinstance(raw, bool): + return raw + s = str(raw).strip().lower() + if s in ("true", "1", "yes", "on"): + return True + if s in ("false", "0", "no", "off"): + return False + raise ValueError(f"ערך bool לא חוקי: {raw}") + if spec.type == "int": + try: + v = int(raw) + except (TypeError, ValueError): + raise ValueError(f"ערך int לא חוקי: {raw}") + if spec.min is not None and v < spec.min: + raise ValueError(f"ערך {v} מתחת למינימום {spec.min}") + if spec.max is not None and v > spec.max: + raise ValueError(f"ערך {v} מעל המקסימום {spec.max}") + return v + if spec.type == "float": + try: + v = float(raw) + except (TypeError, ValueError): + raise ValueError(f"ערך float לא חוקי: {raw}") + if spec.min is not None and v < spec.min: + raise ValueError(f"ערך {v} מתחת למינימום {spec.min}") + if spec.max is not None and v > spec.max: + raise ValueError(f"ערך {v} מעל המקסימום {spec.max}") + return v + # string + s = str(raw) + if spec.enum_values and s not in spec.enum_values: + raise ValueError(f"ערך לא ברשימה: {spec.enum_values}") + return s + + +def normalize_for_compare(spec: EnvSpec, raw: str | None) -> str | None: + """Normalize a raw env string to a canonical form for drift comparison.""" + if raw is None: + return None + try: + v = coerce(spec, raw) + except ValueError: + return raw # invalid value — compare as-is, drift will surface + if spec.type == "bool": + return "true" if v else "false" + return str(v)