Files
legal-ai/docs/superpowers/plans/2026-05-04-mcp-settings-page.md
Chaim 796f9d5f9c docs(plans): add implementation plan for MCP settings page
11 tasks across backend (catalog, env GET/PATCH, redeploy, tools introspection,
registrations) and frontend (tabs refactor, environment with drift detection,
tools drawer, registrations). Includes Coolify volume runbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:58:53 +00:00

70 KiB
Raw Blame History

דף הגדרות MCP — תוכנית מימוש

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: הוספת טאבים חדשים בדף /settings להצגה ועריכה של הגדרות ה-MCP server של legal-ai (env vars מ-Infisical/Coolify, Tools introspection, Registrations).

Architecture: Frontend Next.js (shadcn Tabs) צורך 5 endpoints חדשים תחת /api/settings/mcp/* ב-FastAPI. Catalog סטטי ממפה env keys מותרים לטיפוס/קטגוריה/סוד. Infisical = single source of truth לעריכה; Coolify redeploy ידני להחלת שינויים. Volume mounts ל-~/.claude.json ו-~/.paperclip כקריאה-בלבד מאפשרים ל-Registrations endpoint לקרוא קבצים מארח.

Tech Stack: FastAPI, asyncpg, infisical-python (InfisicalSDKClient), Next.js 16, TanStack Query, shadcn/ui (Tabs, Switch, Select, Drawer), httpx (Coolify API).

Spec: docs/superpowers/specs/2026-05-04-mcp-settings-page-design.md


File Structure

Backend (חדש):

  • web/mcp_env_catalog.py — catalog ו-helpers לטיפוס/ולידציה
  • web/mcp_introspection.py — קריאת tools מ-FastMCP + מיקום קוד
  • web/mcp_registrations.py — קריאת /host/.claude.json ו-/host/.paperclip/...

Backend (modified):

  • web/app.py — 5 endpoints חדשים תחת /api/settings/mcp/*

Frontend (חדש):

  • web-ui/src/app/settings/_components/paperclip-tab.tsx
  • web-ui/src/app/settings/_components/environment-tab.tsx
  • web-ui/src/app/settings/_components/env-var-row.tsx
  • web-ui/src/app/settings/_components/env-var-editor.tsx
  • web-ui/src/app/settings/_components/drift-badge.tsx
  • web-ui/src/app/settings/_components/tools-tab.tsx
  • web-ui/src/app/settings/_components/tool-detail-drawer.tsx
  • web-ui/src/app/settings/_components/registrations-tab.tsx

Frontend (modified):

  • web-ui/src/app/settings/page.tsx — refactor ל-Tabs
  • web-ui/src/lib/api/settings.ts — 5 hooks חדשים

Documentation:

  • docs/runbooks/coolify-mcp-settings-volumes.md — הוראות הוספת volume mounts ל-Coolify

Task 1: Backend — Env Catalog

Files:

  • Create: web/mcp_env_catalog.py

  • Step 1: צור את ה-catalog

# 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)
  • Step 2: בדיקת ייבוא
cd /home/chaim/legal-ai && python3 -c "from web.mcp_env_catalog import ENV_CATALOG, coerce, mask_secret; print(len(ENV_CATALOG), 'keys'); print(mask_secret('sk-1234567890'))"

Expected:

17 keys
****7890
  • Step 3: Commit
git add web/mcp_env_catalog.py
git commit -m "feat(settings): add MCP env catalog with type validation"

Task 2: Backend — GET /api/settings/mcp/env

Files:

  • Modify: web/app.py (הוספת imports + endpoint, ליד endpoints קיימים של settings, אחרי שורה ~2608)

  • Step 1: קריאת ה-Coolify token והוספת helper לקריאה מ-Infisical

הוסף בראש web/app.py (אחרי שורה 31):

from web.mcp_env_catalog import (
    ENV_CATALOG,
    EnvSpec,
    coerce,
    mask_secret,
    normalize_for_compare,
)

הוסף בלוק helpers חדש לפני # ── Settings: Tag → Company Mappings ── (לפני שורה 2546):

# ── Settings: MCP Server Configuration ────────────────────────────


def _infisical_client():
    """Build Infisical SDK client, or return None if not configured."""
    token = os.environ.get("INFISICAL_TOKEN", "")
    if not token:
        return None
    try:
        from infisical_sdk import InfisicalSDKClient
        return InfisicalSDKClient(token=token)
    except Exception as e:
        logger.warning("infisical_client_unavailable: %s", e)
        return None


def _infisical_ctx():
    """Return (project_id, environment, secret_path) for legal-ai secrets."""
    return (
        os.environ.get("INFISICAL_PROJECT_ID", "9a77b161-f70c-4dd3-9d67-b7ab850cef51"),
        os.environ.get("INFISICAL_ENV", "dev"),
        os.environ.get("INFISICAL_PATH", "/legal-ai"),
    )


def _read_infisical_values() -> tuple[dict[str, str], list[str]]:
    """Read all known catalog keys from Infisical. Returns (values, errors)."""
    client = _infisical_client()
    if client is None:
        return {}, ["infisical_unreachable"]
    project_id, env, path = _infisical_ctx()
    try:
        secrets = client.get_all_secrets(
            environment=env, project_id=project_id, secret_path=path
        )
    except Exception as e:
        logger.warning("infisical_read_failed: %s", e)
        return {}, [f"infisical_read_failed: {e}"]
    values: dict[str, str] = {}
    for s in secrets:
        if s.secret_key in ENV_CATALOG:
            values[s.secret_key] = s.secret_value
    return values, []


def _build_env_var_row(
    spec: EnvSpec,
    infisical_value: str | None,
    container_value: str | None,
) -> dict[str, Any]:
    """Build a single response row for an env var."""
    if spec.is_secret:
        i_norm = mask_secret(infisical_value) if infisical_value else None
        c_norm = mask_secret(container_value) if container_value else None
        # drift: compare raw before masking
        drift = (
            (infisical_value or "") != (container_value or "")
            and bool(infisical_value or container_value)
        )
        infisical_display: str | None = i_norm
        container_display: str | None = c_norm
    else:
        infisical_display = infisical_value
        container_display = container_value
        drift = (
            normalize_for_compare(spec, infisical_value)
            != normalize_for_compare(spec, container_value)
        )
        # only count as drift if at least one side is non-null
        if infisical_value is None and container_value is None:
            drift = False
    row = spec.to_public_dict()
    row.update({
        "infisical_value": infisical_display,
        "container_value": container_display,
        "drift": drift,
    })
    return row
  • Step 2: הוסף endpoint GET /api/settings/mcp/env

המשך בלוק MCP Settings:

@app.get("/api/settings/mcp/env")
async def api_mcp_env():
    """List all catalog env vars with Infisical + container values."""
    infisical_values, errors = _read_infisical_values()
    project_id, env, path = _infisical_ctx()
    rows = []
    for key, spec in ENV_CATALOG.items():
        i_val = infisical_values.get(key)
        c_val = os.environ.get(key)
        rows.append(_build_env_var_row(spec, i_val, c_val))
    return {
        "vars": rows,
        "infisical_environment": env,
        "infisical_project_id": project_id,
        "infisical_path": path,
        "coolify_app_uuid": os.environ.get(
            "COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio"
        ),
        "errors": errors,
    }
  • Step 3: Restart backend ובדיקת endpoint

המערכת רצה כ-Docker container ב-Coolify. כדי לבדוק במקום, להריץ deploy ולחכות שהקונטיינר יעלה.

# Commit + push קודם (Coolify יבנה אוטומטית) — נעשה ב-Step 4
# הפעלת קונטיינר נעשית בסוף ה-task כשמכינים deploy עם כל הקוד
  • Step 4: Commit
git add web/app.py
git commit -m "feat(settings): add GET /api/settings/mcp/env endpoint"

Task 3: Backend — PATCH /api/settings/mcp/env/{key} + Redeploy

Files:

  • Modify: web/app.py

  • Step 1: PATCH endpoint לעדכון env var

הוסף אחרי api_mcp_env:

class McpEnvUpdateRequest(BaseModel):
    value: Any


@app.patch("/api/settings/mcp/env/{key}")
async def api_mcp_env_update(key: str, req: McpEnvUpdateRequest):
    """Update a non-secret env var in Infisical. Requires redeploy to take effect."""
    spec = ENV_CATALOG.get(key)
    if spec is None:
        raise HTTPException(404, f"Unknown env key: {key}")
    if spec.is_secret:
        raise HTTPException(400, f"Cannot edit secret: {key}")
    if not spec.is_editable:
        raise HTTPException(400, f"Read-only: {key}")
    try:
        coerced = coerce(spec, req.value)
    except ValueError as e:
        raise HTTPException(400, str(e))

    client = _infisical_client()
    if client is None:
        raise HTTPException(503, "Infisical not configured")
    project_id, env, path = _infisical_ctx()
    str_value = "true" if coerced is True else (
        "false" if coerced is False else str(coerced)
    )
    try:
        # SDK pattern: try update, fall back to create if missing.
        # NOTE: exact method may vary by infisical-python version. The
        # canonical method is `update_secret_by_name`; if your version
        # uses `secrets.update`, replace accordingly.
        try:
            client.update_secret_by_name(
                project_id=project_id,
                environment_slug=env,
                secret_path=path,
                secret_name=key,
                secret_value=str_value,
            )
        except Exception:
            client.create_secret_by_name(
                project_id=project_id,
                environment_slug=env,
                secret_path=path,
                secret_name=key,
                secret_value=str_value,
            )
    except Exception as e:
        logger.exception("infisical_write_failed key=%s", key)
        raise HTTPException(502, f"Infisical write failed: {e}")

    logger.info(
        "mcp_env_update key=%s value=%s",
        key,
        "[masked]" if spec.is_secret else str_value,
    )
    return {
        "ok": True,
        "key": key,
        "saved_value": str_value,
        "requires_redeploy": True,
        "message": "נשמר ב-Infisical. נדרש redeploy כדי שיכנס לתוקף בקונטיינר.",
    }
  • Step 2: Redeploy endpoint

הוסף אחרי PATCH:

@app.post("/api/settings/mcp/env/redeploy")
async def api_mcp_env_redeploy():
    """Trigger Coolify redeploy of the legal-ai app."""
    import httpx
    coolify_url = os.environ.get("COOLIFY_URL", "http://158.178.131.193:8000")
    coolify_token = os.environ.get("COOLIFY_API_TOKEN", "")
    app_uuid = os.environ.get("COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio")
    if not coolify_token:
        raise HTTPException(503, "COOLIFY_API_TOKEN not configured")

    async with httpx.AsyncClient(timeout=30.0) as http:
        try:
            resp = await http.post(
                f"{coolify_url}/api/v1/deploy",
                params={"uuid": app_uuid, "force": "false"},
                headers={"Authorization": f"Bearer {coolify_token}"},
            )
        except Exception as e:
            raise HTTPException(502, f"Coolify unreachable: {e}")
    if resp.status_code >= 400:
        raise HTTPException(
            502, f"Coolify deploy failed: {resp.status_code} {resp.text}"
        )
    data = resp.json() if resp.content else {}
    deployment_uuid = (
        data.get("deployment_uuid")
        or (data.get("deployments") or [{}])[0].get("deployment_uuid")
    )
    logger.info("mcp_env_redeploy triggered uuid=%s", deployment_uuid)
    return {
        "ok": True,
        "deployment_uuid": deployment_uuid,
        "message": "Redeploy הופעל. הקונטיינר יחזור תוך 2-4 דקות.",
    }
  • Step 3: Commit
git add web/app.py
git commit -m "feat(settings): add PATCH env + Coolify redeploy endpoints"

Task 4: Backend — Tools Introspection

Files:

  • Create: web/mcp_introspection.py

  • Modify: web/app.py

  • Step 1: צור module ל-introspection

# web/mcp_introspection.py
"""Introspect MCP tools from the FastMCP instance."""

from __future__ import annotations

import inspect
from typing import Any


async def list_mcp_tools() -> list[dict[str, Any]]:
    """List all registered MCP tools with metadata."""
    from legal_mcp.server import mcp

    tools = await mcp.list_tools()
    out: list[dict[str, Any]] = []
    for t in tools:
        # Resolve underlying callable for source location
        fn = _resolve_callable(t.name)
        source_location = ""
        module = ""
        if fn is not None:
            try:
                file = inspect.getfile(fn)
                _, line = inspect.getsourcelines(fn)
                source_location = f"{file}:{line}"
                module = fn.__module__
            except Exception:
                pass
        out.append({
            "name": t.name,
            "description": t.description or "",
            "params_schema": getattr(t, "inputSchema", None),
            "module": module,
            "source_location": source_location,
        })
    return sorted(out, key=lambda r: (r["module"], r["name"]))


def _resolve_callable(tool_name: str):
    """Find the python function backing a registered tool name."""
    from legal_mcp import tools as tools_pkg

    for module_name in tools_pkg.__all__ if hasattr(tools_pkg, "__all__") else []:
        # fallback: scan known submodules
        pass
    # Direct scan of known submodules
    from legal_mcp.tools import (
        cases, documents, drafting, precedent_library,
        precedents, search, workflow,
    )
    for mod in (
        cases, documents, drafting, precedent_library,
        precedents, search, workflow,
    ):
        fn = getattr(mod, tool_name, None)
        if callable(fn):
            return fn
    return None
  • Step 2: הוסף endpoint GET /api/settings/mcp/tools

ב-web/app.py, אחרי endpoint redeploy:

@app.get("/api/settings/mcp/tools")
async def api_mcp_tools():
    """List all MCP tools registered in legal_mcp."""
    from web.mcp_introspection import list_mcp_tools
    try:
        tools = await list_mcp_tools()
    except Exception as e:
        logger.exception("mcp_tools_introspection_failed")
        raise HTTPException(500, f"Tools introspection failed: {e}")
    return {"tools": tools, "count": len(tools)}
  • Step 3: בדיקת import
cd /home/chaim/legal-ai && python3 -c "from web.mcp_introspection import list_mcp_tools; import asyncio; print(asyncio.run(list_mcp_tools())[:2])"

Expected: רשימה של dicts עם name, description, module, source_location. אם יש שגיאת import של legal_mcp.server (כי DB לא זמין מקומית), זה צפוי — נריץ end-to-end רק בקונטיינר.

  • Step 4: Commit
git add web/mcp_introspection.py web/app.py
git commit -m "feat(settings): add MCP tools introspection endpoint"

Task 5: Backend — Registrations + Coolify Volume Setup

Files:

  • Create: web/mcp_registrations.py

  • Create: docs/runbooks/coolify-mcp-settings-volumes.md

  • Modify: web/app.py

  • Step 1: צור module לקריאת רישומי MCP

# web/mcp_registrations.py
"""Read MCP server registrations from host config files mounted in /host."""

from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

HOST_DIR = Path("/host")


def _redact_env_keys(env: dict[str, Any] | None) -> list[str]:
    if not env or not isinstance(env, dict):
        return []
    return sorted(env.keys())


def _read_claude_registrations() -> list[dict[str, Any]]:
    """Read MCP registrations from /host/.claude.json."""
    path = HOST_DIR / ".claude.json"
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception as e:
        logger.warning("claude_json_parse_failed: %s", e)
        return []
    out: list[dict[str, Any]] = []
    # Top-level mcpServers
    for name, cfg in (data.get("mcpServers") or {}).items():
        out.append(_normalize("Claude Code (global)", name, cfg))
    # Per-project mcpServers
    for project_path, project_cfg in (data.get("projects") or {}).items():
        for name, cfg in (project_cfg.get("mcpServers") or {}).items():
            out.append(_normalize(f"Claude Code ({project_path})", name, cfg))
    return out


def _read_paperclip_registrations() -> list[dict[str, Any]]:
    """Read MCP registrations from /host/.paperclip/instances/*/mcp.json."""
    base = HOST_DIR / ".paperclip" / "instances"
    if not base.exists():
        return []
    out: list[dict[str, Any]] = []
    for instance_dir in sorted(base.iterdir()):
        if not instance_dir.is_dir():
            continue
        mcp_json = instance_dir / "mcp.json"
        if not mcp_json.exists():
            continue
        try:
            data = json.loads(mcp_json.read_text(encoding="utf-8"))
        except Exception as e:
            logger.warning(
                "paperclip_mcp_json_parse_failed: %s %s",
                instance_dir.name, e,
            )
            continue
        for name, cfg in (data.get("mcpServers") or data or {}).items():
            if not isinstance(cfg, dict):
                continue
            out.append(_normalize(f"Paperclip ({instance_dir.name})", name, cfg))
    return out


def _normalize(client: str, server_name: str, cfg: dict[str, Any]) -> dict[str, Any]:
    return {
        "client": client,
        "server_name": server_name,
        "command": cfg.get("command", ""),
        "args": cfg.get("args") or [],
        "cwd": cfg.get("cwd") or cfg.get("workingDirectory") or "",
        "env_keys": _redact_env_keys(cfg.get("env")),
        "transport": cfg.get("transport") or "stdio",
    }


def list_registrations() -> dict[str, Any]:
    """Return all MCP registrations + status."""
    if not HOST_DIR.exists():
        return {
            "registrations": [],
            "error": "host_path_unavailable",
            "message": "תיקיית /host לא mounted. ראה runbook להגדרת volumes ב-Coolify.",
        }
    return {
        "registrations": (
            _read_claude_registrations() + _read_paperclip_registrations()
        ),
        "error": None,
    }
  • Step 2: הוסף endpoint GET /api/settings/mcp/registrations

ב-web/app.py, אחרי tools endpoint:

@app.get("/api/settings/mcp/registrations")
async def api_mcp_registrations():
    """List MCP server registrations from host config files."""
    from web.mcp_registrations import list_registrations
    return list_registrations()
  • Step 3: כתוב runbook ל-volume mounts
<!-- docs/runbooks/coolify-mcp-settings-volumes.md -->
# Coolify Volume Mounts ל-MCP Settings Page

## רקע

טאב **Registrations** בדף `/settings` קורא רישומי MCP מתוך:
- `~/.claude.json` (host)
- `~/.paperclip/instances/*/mcp.json` (host)

הקונטיינר של legal-ai חייב גישת קריאה לקבצים אלה דרך volume mounts.
בלי המאונט, ה-endpoint יחזיר `error: "host_path_unavailable"` והטאב יציג הודעת אי-זמינות.

## הוראות

1. פתח Coolify UI: `http://158.178.131.193:8000`.
2. נווט לאפליקציה: legal-ai (UUID `gyjo0mtw2c42ej3xxvbz8zio`).
3. לשונית **Storages****Add Storage**.
4. הוסף שני mounts:

   | Source path (host) | Destination path (container) | Mode |
   |---|---|---|
   | `/home/chaim/.claude.json` | `/host/.claude.json` | `ro` |
   | `/home/chaim/.paperclip` | `/host/.paperclip` | `ro` |

5. שמור ולחץ **Redeploy**.

## אימות

אחרי ה-redeploy:
```bash
curl -s https://legal-ai.nautilus.marcusgroup.org/api/settings/mcp/registrations | jq

צריך להחזיר "error": null ורשימת רישומים.

הערה אבטחה

המאונטים הם read-only. ה-endpoint לא מחזיר ערכי env (רק שמות keys), ולא מאפשר לעדכן את הקבצים.


- [ ] **Step 4: Commit**

```bash
git add web/mcp_registrations.py web/app.py docs/runbooks/coolify-mcp-settings-volumes.md
git commit -m "feat(settings): add MCP registrations endpoint + Coolify volume runbook"

Task 6: Frontend — API Hooks

Files:

  • Modify: web-ui/src/lib/api/settings.ts

  • Step 1: הוסף types ו-hooks חדשים

הוסף בסוף web-ui/src/lib/api/settings.ts:

// ── MCP Settings ────────────────────────────────────────────────

export type EnvCategory =
  | "multimodal"
  | "rerank"
  | "halacha"
  | "credentials"
  | "connection"
  | "general";

export type EnvType = "bool" | "int" | "float" | "string";

export type McpEnvVar = {
  key: string;
  category: EnvCategory;
  type: EnvType;
  description: string;
  is_secret: boolean;
  is_editable: boolean;
  default: unknown;
  min: number | null;
  max: number | null;
  enum_values: string[] | null;
  infisical_value: string | null;
  container_value: string | null;
  drift: boolean;
};

export type McpEnvResponse = {
  vars: McpEnvVar[];
  infisical_environment: string;
  infisical_project_id: string;
  infisical_path: string;
  coolify_app_uuid: string;
  errors: string[];
};

export type McpTool = {
  name: string;
  description: string;
  params_schema: unknown;
  module: string;
  source_location: string;
};

export type McpRegistration = {
  client: string;
  server_name: string;
  command: string;
  args: string[];
  cwd: string;
  env_keys: string[];
  transport: string;
};

export function useMcpEnv() {
  return useQuery({
    queryKey: ["settings", "mcp-env"] as const,
    queryFn: ({ signal }) =>
      apiRequest<McpEnvResponse>("/api/settings/mcp/env", { signal }),
    staleTime: 5_000,
  });
}

export function useUpdateMcpEnv() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ key, value }: { key: string; value: unknown }) =>
      apiRequest<{
        ok: boolean;
        key: string;
        saved_value: string;
        requires_redeploy: boolean;
        message: string;
      }>(`/api/settings/mcp/env/${encodeURIComponent(key)}`, {
        method: "PATCH",
        body: { value },
      }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "mcp-env"] }),
  });
}

export function useMcpRedeploy() {
  return useMutation({
    mutationFn: () =>
      apiRequest<{ ok: boolean; deployment_uuid: string | null; message: string }>(
        "/api/settings/mcp/env/redeploy",
        { method: "POST" },
      ),
  });
}

export function useMcpTools() {
  return useQuery({
    queryKey: ["settings", "mcp-tools"] as const,
    queryFn: ({ signal }) =>
      apiRequest<{ tools: McpTool[]; count: number }>("/api/settings/mcp/tools", {
        signal,
      }),
    staleTime: 60_000,
  });
}

export function useMcpRegistrations() {
  return useQuery({
    queryKey: ["settings", "mcp-registrations"] as const,
    queryFn: ({ signal }) =>
      apiRequest<{
        registrations: McpRegistration[];
        error: string | null;
        message?: string;
      }>("/api/settings/mcp/registrations", { signal }),
    staleTime: 60_000,
  });
}
  • Step 2: TypeScript check
cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit

Expected: 0 errors.

  • Step 3: Commit
git add web-ui/src/lib/api/settings.ts
git commit -m "feat(settings): add MCP API hooks"

Task 7: Frontend — Refactor Page to Tabs + Paperclip Tab

Files:

  • Create: web-ui/src/app/settings/_components/paperclip-tab.tsx

  • Modify: web-ui/src/app/settings/page.tsx

  • Step 1: העבר את כל התוכן הקיים לקומפוננטה paperclip-tab.tsx

// web-ui/src/app/settings/_components/paperclip-tab.tsx
"use client";

import { useState } from "react";
import { Plus, Trash2, Tags, Building2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  useTagMappings,
  usePaperclipCompanies,
  useAddTagMapping,
  useDeleteTagMapping,
} from "@/lib/api/settings";
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
import { toast } from "sonner";

const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown");

export function PaperclipTab() {
  const { data: mappings, isPending: loadingMappings } = useTagMappings();
  const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
  const addMapping = useAddTagMapping();
  const deleteMapping = useDeleteTagMapping();

  const [tag, setTag] = useState("");
  const [tagLabel, setTagLabel] = useState("");
  const [companyId, setCompanyId] = useState("");

  function handleTagInput(value: string) {
    setTag(value);
    const match = TAG_SUGGESTIONS.find((s) => s.value === value);
    if (match) setTagLabel(match.label);
  }

  function handleAdd() {
    if (!tag || !companyId) {
      toast.error("יש לבחור תגית וחברה");
      return;
    }
    const company = companies?.find((c) => c.id === companyId);
    addMapping.mutate(
      {
        tag,
        tag_label: tagLabel,
        company_id: companyId,
        company_name: company?.name ?? "",
      },
      {
        onSuccess: () => {
          toast.success("מיפוי נוסף בהצלחה");
          setTag("");
          setTagLabel("");
          setCompanyId("");
        },
        onError: (err) => toast.error(`שגיאה: ${err.message}`),
      },
    );
  }

  function handleDelete(id: string, tag: string) {
    deleteMapping.mutate(id, {
      onSuccess: () => toast.success(`מיפוי "${tag}" נמחק`),
      onError: (err) => toast.error(`שגיאה: ${err.message}`),
    });
  }

  return (
    <div className="space-y-6">
      <Card className="bg-surface border-rule shadow-sm">
        <CardContent className="px-6 py-5">
          <h2 className="text-navy text-lg mb-3 flex items-center gap-2">
            <Building2 className="w-4 h-4" />
            חברות ב-Paperclip
          </h2>
          {loadingCompanies ? (
            <Skeleton className="h-12 w-full" />
          ) : !companies?.length ? (
            <p className="text-ink-muted text-sm">לא נמצאו חברות</p>
          ) : (
            <div className="flex flex-wrap gap-3">
              {companies.map((c) => (
                <div
                  key={c.id}
                  className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5"
                >
                  <span className="text-sm font-medium text-ink">{c.name}</span>
                  <Badge variant="outline" className="text-[0.7rem] tabular-nums">
                    {c.prefix}
                  </Badge>
                </div>
              ))}
            </div>
          )}
        </CardContent>
      </Card>

      <Card className="bg-surface border-rule shadow-sm">
        <CardContent className="px-6 py-5">
          <h2 className="text-navy text-lg mb-4 flex items-center gap-2">
            <Tags className="w-4 h-4" />
            מיפוי תגיות
            <Badge variant="outline" className="text-[0.7rem] tabular-nums">
              {mappings?.length ?? 0}
            </Badge>
          </h2>

          <div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
            <div className="flex flex-col gap-1.5 min-w-[180px]">
              <label className="text-[0.72rem] text-ink-muted">תגית</label>
              <Input
                list="tag-suggestions"
                value={tag}
                onChange={(e) => handleTagInput(e.target.value)}
                placeholder="סוג ערר או תגית חופשית"
                className="w-[220px]"
              />
              <datalist id="tag-suggestions">
                {TAG_SUGGESTIONS.map((s) => (
                  <option key={s.value} value={s.value}>
                    {s.label}
                  </option>
                ))}
              </datalist>
            </div>

            <div className="flex flex-col gap-1.5 min-w-[140px]">
              <label className="text-[0.72rem] text-ink-muted">תווית</label>
              <Input
                value={tagLabel}
                onChange={(e) => setTagLabel(e.target.value)}
                placeholder="שם לתצוגה"
                className="w-[160px]"
              />
            </div>

            <div className="flex flex-col gap-1.5 min-w-[200px]">
              <label className="text-[0.72rem] text-ink-muted">
                חברה ב-Paperclip
              </label>
              <Select value={companyId} onValueChange={setCompanyId}>
                <SelectTrigger className="w-[240px]">
                  <SelectValue placeholder="בחר חברה" />
                </SelectTrigger>
                <SelectContent>
                  {companies?.map((c) => (
                    <SelectItem key={c.id} value={c.id}>
                      {c.name} ({c.prefix})
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>

            <Button
              onClick={handleAdd}
              disabled={addMapping.isPending || !tag || !companyId}
              size="default"
            >
              <Plus className="w-4 h-4" data-icon="inline-start" />
              {addMapping.isPending ? "שומר..." : "הוסף מיפוי"}
            </Button>
          </div>

          {loadingMappings ? (
            <Skeleton className="h-32 w-full" />
          ) : !mappings?.length ? (
            <p className="text-ink-muted text-sm">
              אין מיפויים. הוסף מיפוי כדי שתיקים חדשים ישויכו אוטומטית
              לפרויקט בחברה הנכונה.
            </p>
          ) : (
            <div className="overflow-x-auto">
              <table className="w-full text-sm">
                <thead>
                  <tr className="border-b border-rule text-ink-muted text-[0.72rem] uppercase tracking-wider">
                    <th className="text-start py-2 px-3 font-medium">Tag</th>
                    <th className="text-start py-2 px-3 font-medium">Label</th>
                    <th className="text-start py-2 px-3 font-medium">Company</th>
                    <th className="py-2 px-3 w-12" />
                  </tr>
                </thead>
                <tbody>
                  {mappings.map((m) => (
                    <tr
                      key={m.id}
                      className="border-b border-rule/60 hover:bg-rule-soft/40 transition-colors"
                    >
                      <td className="py-2.5 px-3">
                        <Badge variant="outline" className="text-[0.75rem] font-mono">
                          {m.tag}
                        </Badge>
                      </td>
                      <td className="py-2.5 px-3 text-ink">{m.tag_label}</td>
                      <td className="py-2.5 px-3 text-ink">{m.company_name}</td>
                      <td className="py-2.5 px-3">
                        <Button
                          variant="ghost"
                          size="icon-xs"
                          onClick={() => handleDelete(m.id, m.tag)}
                          disabled={deleteMapping.isPending}
                          title="מחק מיפוי"
                        >
                          <Trash2 className="w-3.5 h-3.5 text-danger" />
                        </Button>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          )}
        </CardContent>
      </Card>
    </div>
  );
}
  • Step 2: Refactor page.tsx ל-Tabs
// web-ui/src/app/settings/page.tsx
"use client";

import Link from "next/link";
import { Server, Wrench, Plug, Building2 } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PaperclipTab } from "./_components/paperclip-tab";
import { EnvironmentTab } from "./_components/environment-tab";
import { ToolsTab } from "./_components/tools-tab";
import { RegistrationsTab } from "./_components/registrations-tab";

export default function SettingsPage() {
  return (
    <AppShell>
      <section className="space-y-6">
        <header>
          <nav className="text-[0.78rem] text-ink-muted mb-1">
            <Link href="/" className="hover:text-gold-deep">
              בית
            </Link>
            <span aria-hidden> · </span>
            <span className="text-navy">הגדרות</span>
          </nav>
          <h1 className="text-navy mb-0">הגדרות</h1>
          <p className="text-ink-muted text-sm mt-1 max-w-2xl">
            תצורת המערכת, MCP server, ו-Paperclip integration.
          </p>
        </header>

        <div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />

        <Tabs defaultValue="paperclip" className="space-y-4">
          <TabsList>
            <TabsTrigger value="paperclip">
              <Building2 className="w-4 h-4" data-icon="inline-start" />
              Paperclip
            </TabsTrigger>
            <TabsTrigger value="environment">
              <Server className="w-4 h-4" data-icon="inline-start" />
              Environment
            </TabsTrigger>
            <TabsTrigger value="tools">
              <Wrench className="w-4 h-4" data-icon="inline-start" />
              Tools
            </TabsTrigger>
            <TabsTrigger value="registrations">
              <Plug className="w-4 h-4" data-icon="inline-start" />
              Registrations
            </TabsTrigger>
          </TabsList>

          <TabsContent value="paperclip"><PaperclipTab /></TabsContent>
          <TabsContent value="environment"><EnvironmentTab /></TabsContent>
          <TabsContent value="tools"><ToolsTab /></TabsContent>
          <TabsContent value="registrations"><RegistrationsTab /></TabsContent>
        </Tabs>
      </section>
    </AppShell>
  );
}

הערה: הקובץ ייכשל לקמפל עד שיווצרו 3 הקומפוננטות בטסקים 8-10. בשלב זה יוצרים stubs כדי לקמפל.

  • Step 3: צור stubs לקומפוננטות החסרות
// web-ui/src/app/settings/_components/environment-tab.tsx
export function EnvironmentTab() { return <div>Environment tab  coming soon</div>; }
// web-ui/src/app/settings/_components/tools-tab.tsx
export function ToolsTab() { return <div>Tools tab  coming soon</div>; }
// web-ui/src/app/settings/_components/registrations-tab.tsx
export function RegistrationsTab() { return <div>Registrations tab  coming soon</div>; }
  • Step 4: TypeScript check + lint
cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit && npm run lint

Expected: 0 errors.

  • Step 5: Commit
git add web-ui/src/app/settings/
git commit -m "refactor(settings): split into tabs (paperclip + 3 stubs)"

Task 8: Frontend — Environment Tab (Read + Edit)

Files:

  • Create: web-ui/src/app/settings/_components/drift-badge.tsx

  • Create: web-ui/src/app/settings/_components/env-var-editor.tsx

  • Create: web-ui/src/app/settings/_components/env-var-row.tsx

  • Modify: web-ui/src/app/settings/_components/environment-tab.tsx (replace stub)

  • Step 1: Drift badge component

// web-ui/src/app/settings/_components/drift-badge.tsx
"use client";

import { AlertTriangle, CheckCircle2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";

export function DriftBadge({ drift }: { drift: boolean }) {
  if (drift) {
    return (
      <Badge variant="outline" className="text-warn border-warn/40 gap-1">
        <AlertTriangle className="w-3 h-3" />
        Drift
      </Badge>
    );
  }
  return (
    <Badge variant="outline" className="text-success border-success/40 gap-1">
      <CheckCircle2 className="w-3 h-3" />
      Synced
    </Badge>
  );
}
  • Step 2: Env var editor (control לפי type)
// web-ui/src/app/settings/_components/env-var-editor.tsx
"use client";

import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import type { McpEnvVar } from "@/lib/api/settings";

type Props = {
  spec: McpEnvVar;
  value: string;
  onChange: (v: string) => void;
  disabled?: boolean;
};

export function EnvVarEditor({ spec, value, onChange, disabled }: Props) {
  if (spec.type === "bool") {
    const checked = value === "true";
    return (
      <Switch
        checked={checked}
        onCheckedChange={(c) => onChange(c ? "true" : "false")}
        disabled={disabled}
      />
    );
  }

  if (spec.enum_values && spec.enum_values.length > 0) {
    return (
      <Select value={value} onValueChange={onChange} disabled={disabled}>
        <SelectTrigger className="w-[220px]">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {spec.enum_values.map((v) => (
            <SelectItem key={v} value={v}>
              {v}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    );
  }

  if (spec.type === "int" || spec.type === "float") {
    return (
      <Input
        type="number"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        min={spec.min ?? undefined}
        max={spec.max ?? undefined}
        step={spec.type === "float" ? "0.01" : "1"}
        disabled={disabled}
        className="w-[160px] text-start"
        dir="ltr"
      />
    );
  }

  return (
    <Input
      type="text"
      value={value}
      onChange={(e) => onChange(e.target.value)}
      disabled={disabled}
      className="w-[260px] text-start"
      dir="ltr"
    />
  );
}
  • Step 3: Env var row
// web-ui/src/app/settings/_components/env-var-row.tsx
"use client";

import { useState } from "react";
import { ExternalLink, Save, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { McpEnvVar } from "@/lib/api/settings";
import { useUpdateMcpEnv } from "@/lib/api/settings";
import { toast } from "sonner";
import { DriftBadge } from "./drift-badge";
import { EnvVarEditor } from "./env-var-editor";

type Props = {
  spec: McpEnvVar;
  infisicalProjectId: string;
  infisicalEnv: string;
  onPendingRedeploy: () => void;
};

export function EnvVarRow({
  spec,
  infisicalProjectId,
  infisicalEnv,
  onPendingRedeploy,
}: Props) {
  const [draft, setDraft] = useState<string>(spec.infisical_value ?? "");
  const update = useUpdateMcpEnv();
  const dirty = draft !== (spec.infisical_value ?? "");

  function handleSave() {
    update.mutate(
      { key: spec.key, value: draft },
      {
        onSuccess: (res) => {
          toast.success(res.message);
          onPendingRedeploy();
        },
        onError: (err) => toast.error(`שגיאה: ${err.message}`),
      },
    );
  }

  const infisicalUrl =
    `https://secret.dev.marcus-law.co.il/project/${infisicalProjectId}/secrets/overview?env=${infisicalEnv}`;

  return (
    <div className="rounded-md border border-rule p-4 bg-rule-soft/20 hover:bg-rule-soft/40 transition-colors">
      <div className="flex items-start justify-between gap-3 mb-3">
        <div className="flex-1 min-w-0">
          <div className="flex items-center gap-2 flex-wrap">
            <code className="font-mono text-sm font-medium text-navy" dir="ltr">
              {spec.key}
            </code>
            <Badge variant="outline" className="text-[0.7rem]">
              {spec.type}
            </Badge>
            {spec.is_secret && (
              <Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40 gap-1">
                <Lock className="w-3 h-3" />
                secret
              </Badge>
            )}
            <DriftBadge drift={spec.drift} />
          </div>
          <p className="text-sm text-ink-muted mt-1">{spec.description}</p>
        </div>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
        <div className="flex items-center gap-2">
          <span className="text-[0.72rem] text-ink-muted w-20">Infisical:</span>
          {spec.is_editable ? (
            <EnvVarEditor
              spec={spec}
              value={draft}
              onChange={setDraft}
              disabled={update.isPending}
            />
          ) : (
            <span className="font-mono text-ink" dir="ltr">
              {spec.infisical_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
            </span>
          )}
        </div>
        <div className="flex items-center gap-2">
          <span className="text-[0.72rem] text-ink-muted w-20">Container:</span>
          <span className="font-mono text-ink" dir="ltr">
            {spec.container_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
          </span>
        </div>
      </div>

      <div className="flex items-center justify-end gap-2 mt-3">
        {!spec.is_editable && (
          <a
            href={infisicalUrl}
            target="_blank"
            rel="noopener noreferrer"
            className="text-[0.78rem] text-gold-deep hover:underline flex items-center gap-1"
          >
            ערוך ב-Infisical
            <ExternalLink className="w-3 h-3" />
          </a>
        )}
        {spec.is_editable && (
          <Button
            size="sm"
            onClick={handleSave}
            disabled={!dirty || update.isPending}
          >
            <Save className="w-3.5 h-3.5" data-icon="inline-start" />
            {update.isPending ? "שומר..." : "שמור"}
          </Button>
        )}
      </div>
    </div>
  );
}
  • Step 4: Environment tab
// web-ui/src/app/settings/_components/environment-tab.tsx
"use client";

import { useState, useMemo } from "react";
import { RefreshCw, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
  useMcpEnv,
  useMcpRedeploy,
  type McpEnvVar,
  type EnvCategory,
} from "@/lib/api/settings";
import { toast } from "sonner";
import { EnvVarRow } from "./env-var-row";

const CATEGORY_LABELS: Record<EnvCategory, string> = {
  multimodal: "Multimodal",
  rerank: "Rerank",
  halacha: "Halacha",
  general: "כללי",
  credentials: "אישורים",
  connection: "חיבורים",
};

const CATEGORY_ORDER: EnvCategory[] = [
  "multimodal", "rerank", "halacha", "general", "credentials", "connection",
];

export function EnvironmentTab() {
  const { data, isPending, error } = useMcpEnv();
  const redeploy = useMcpRedeploy();
  const [pendingRedeploy, setPendingRedeploy] = useState(false);

  const grouped = useMemo(() => {
    if (!data?.vars) return new Map<EnvCategory, McpEnvVar[]>();
    const m = new Map<EnvCategory, McpEnvVar[]>();
    for (const v of data.vars) {
      const arr = m.get(v.category) ?? [];
      arr.push(v);
      m.set(v.category, arr);
    }
    return m;
  }, [data]);

  function handleRedeploy() {
    redeploy.mutate(undefined, {
      onSuccess: (res) => {
        toast.success(res.message);
        setPendingRedeploy(false);
      },
      onError: (err) => toast.error(`Redeploy נכשל: ${err.message}`),
    });
  }

  if (isPending) return <Skeleton className="h-96 w-full" />;
  if (error) {
    return (
      <Card className="bg-surface border-danger/40">
        <CardContent className="p-6 flex items-center gap-3 text-danger">
          <AlertCircle className="w-5 h-5" />
          <span>שגיאה בטעינת env vars: {error.message}</span>
        </CardContent>
      </Card>
    );
  }
  if (!data) return null;

  const driftCount = data.vars.filter((v) => v.drift).length;

  return (
    <div className="space-y-4">
      <Card className="bg-surface border-rule">
        <CardContent className="px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
          <div className="flex items-center gap-3 flex-wrap text-sm">
            <Badge variant="outline">
              Infisical: <code dir="ltr" className="ms-1">{data.infisical_environment}</code>
            </Badge>
            <Badge variant="outline">
              Path: <code dir="ltr" className="ms-1">{data.infisical_path}</code>
            </Badge>
            {driftCount > 0 && (
              <Badge variant="outline" className="text-warn border-warn/40">
                {driftCount} drift
              </Badge>
            )}
            {data.errors.length > 0 && (
              <Badge variant="outline" className="text-danger border-danger/40">
                {data.errors.join(", ")}
              </Badge>
            )}
          </div>
          <Button
            onClick={handleRedeploy}
            disabled={redeploy.isPending}
            variant={pendingRedeploy ? "default" : "outline"}
            size="sm"
          >
            <RefreshCw className={redeploy.isPending ? "w-3.5 h-3.5 animate-spin" : "w-3.5 h-3.5"} data-icon="inline-start" />
            {redeploy.isPending ? "Redeploying..." : "Redeploy now"}
          </Button>
        </CardContent>
      </Card>

      {CATEGORY_ORDER.map((cat) => {
        const vars = grouped.get(cat);
        if (!vars || vars.length === 0) return null;
        return (
          <Card key={cat} className="bg-surface border-rule">
            <CardContent className="px-6 py-5">
              <h2 className="text-navy text-lg mb-4 flex items-center gap-2">
                {CATEGORY_LABELS[cat]}
                <Badge variant="outline" className="text-[0.7rem] tabular-nums">
                  {vars.length}
                </Badge>
              </h2>
              <div className="space-y-3">
                {vars.map((v) => (
                  <EnvVarRow
                    key={v.key}
                    spec={v}
                    infisicalProjectId={data.infisical_project_id}
                    infisicalEnv={data.infisical_environment}
                    onPendingRedeploy={() => setPendingRedeploy(true)}
                  />
                ))}
              </div>
            </CardContent>
          </Card>
        );
      })}
    </div>
  );
}
  • Step 5: TypeScript check
cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit && npm run lint

Expected: 0 errors. אם חסר רכיב Switch ב-shadcn — להריץ:

cd web-ui && npx shadcn@latest add switch
  • Step 6: Commit
git add web-ui/src/app/settings/_components/
git commit -m "feat(settings): implement Environment tab with edit + drift detection"

Task 9: Frontend — Tools Tab

Files:

  • Create: web-ui/src/app/settings/_components/tool-detail-drawer.tsx

  • Modify: web-ui/src/app/settings/_components/tools-tab.tsx (replace stub)

  • Step 1: Tool detail drawer

// web-ui/src/app/settings/_components/tool-detail-drawer.tsx
"use client";

import {
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetDescription,
} from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import type { McpTool } from "@/lib/api/settings";

type Props = {
  tool: McpTool | null;
  open: boolean;
  onOpenChange: (o: boolean) => void;
};

export function ToolDetailDrawer({ tool, open, onOpenChange }: Props) {
  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent side="left" className="sm:max-w-xl overflow-y-auto">
        {tool && (
          <>
            <SheetHeader>
              <SheetTitle dir="ltr" className="font-mono text-navy">
                {tool.name}
              </SheetTitle>
              <SheetDescription>{tool.description || "—"}</SheetDescription>
            </SheetHeader>
            <div className="space-y-4 mt-4 px-4 pb-6">
              <div>
                <div className="text-[0.72rem] text-ink-muted uppercase mb-1">
                  Module
                </div>
                <Badge variant="outline" className="font-mono" dir="ltr">
                  {tool.module}
                </Badge>
              </div>
              <div>
                <div className="text-[0.72rem] text-ink-muted uppercase mb-1">
                  Source
                </div>
                <code dir="ltr" className="text-xs text-ink break-all">
                  {tool.source_location || "—"}
                </code>
              </div>
              <div>
                <div className="text-[0.72rem] text-ink-muted uppercase mb-1">
                  Parameters Schema
                </div>
                <pre
                  dir="ltr"
                  className="text-xs bg-rule-soft/40 border border-rule rounded-md p-3 overflow-x-auto"
                >
                  {JSON.stringify(tool.params_schema, null, 2)}
                </pre>
              </div>
            </div>
          </>
        )}
      </SheetContent>
    </Sheet>
  );
}
  • Step 2: Tools tab
// web-ui/src/app/settings/_components/tools-tab.tsx
"use client";

import { useState, useMemo } from "react";
import { Wrench, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { useMcpTools, type McpTool } from "@/lib/api/settings";
import { ToolDetailDrawer } from "./tool-detail-drawer";

export function ToolsTab() {
  const { data, isPending, error } = useMcpTools();
  const [selected, setSelected] = useState<McpTool | null>(null);
  const [open, setOpen] = useState(false);

  const grouped = useMemo(() => {
    if (!data?.tools) return new Map<string, McpTool[]>();
    const m = new Map<string, McpTool[]>();
    for (const t of data.tools) {
      const mod = t.module.split(".").pop() || "other";
      const arr = m.get(mod) ?? [];
      arr.push(t);
      m.set(mod, arr);
    }
    return m;
  }, [data]);

  if (isPending) return <Skeleton className="h-96 w-full" />;
  if (error) {
    return (
      <Card className="bg-surface border-danger/40">
        <CardContent className="p-6 flex items-center gap-3 text-danger">
          <AlertCircle className="w-5 h-5" />
          <span>שגיאה בטעינת tools: {error.message}</span>
        </CardContent>
      </Card>
    );
  }
  if (!data) return null;

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-2 text-sm text-ink-muted">
        <Wrench className="w-4 h-4" />
        סה"כ {data.count} tools
      </div>
      {[...grouped.entries()].sort().map(([mod, tools]) => (
        <Card key={mod} className="bg-surface border-rule">
          <CardContent className="px-6 py-5">
            <h2 className="text-navy text-lg mb-3 flex items-center gap-2">
              <code dir="ltr">{mod}</code>
              <Badge variant="outline" className="text-[0.7rem]">
                {tools.length}
              </Badge>
            </h2>
            <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
              {tools.map((t) => (
                <button
                  key={t.name}
                  onClick={() => {
                    setSelected(t);
                    setOpen(true);
                  }}
                  className="text-start rounded-md border border-rule px-3 py-2 hover:bg-rule-soft/40 transition-colors"
                >
                  <code dir="ltr" className="font-mono text-sm text-navy">
                    {t.name}
                  </code>
                  {t.description && (
                    <p className="text-[0.78rem] text-ink-muted mt-0.5 line-clamp-2">
                      {t.description}
                    </p>
                  )}
                </button>
              ))}
            </div>
          </CardContent>
        </Card>
      ))}
      <ToolDetailDrawer tool={selected} open={open} onOpenChange={setOpen} />
    </div>
  );
}
  • Step 3: TypeScript check
cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit && npm run lint

Expected: 0 errors. אם חסר Sheet ב-shadcn:

npx shadcn@latest add sheet
  • Step 4: Commit
git add web-ui/src/app/settings/_components/tool-detail-drawer.tsx web-ui/src/app/settings/_components/tools-tab.tsx
git commit -m "feat(settings): implement Tools tab with detail drawer"

Task 10: Frontend — Registrations Tab

Files:

  • Modify: web-ui/src/app/settings/_components/registrations-tab.tsx (replace stub)

  • Step 1: Registrations tab

// web-ui/src/app/settings/_components/registrations-tab.tsx
"use client";

import { Plug, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { useMcpRegistrations } from "@/lib/api/settings";

export function RegistrationsTab() {
  const { data, isPending, error } = useMcpRegistrations();

  if (isPending) return <Skeleton className="h-64 w-full" />;
  if (error) {
    return (
      <Card className="bg-surface border-danger/40">
        <CardContent className="p-6 flex items-center gap-3 text-danger">
          <AlertCircle className="w-5 h-5" />
          <span>שגיאה: {error.message}</span>
        </CardContent>
      </Card>
    );
  }
  if (!data) return null;

  if (data.error === "host_path_unavailable") {
    return (
      <Card className="bg-surface border-warn/40">
        <CardContent className="p-6">
          <div className="flex items-center gap-3 text-warn mb-2">
            <AlertCircle className="w-5 h-5" />
            <span className="font-medium">תיקיית /host לא זמינה בקונטיינר</span>
          </div>
          <p className="text-sm text-ink-muted mb-2">
            כדי להציג רישומי MCP, יש להוסיף volume mounts ב-Coolify.
            ראה runbook ב-
            <code dir="ltr" className="mx-1">
              docs/runbooks/coolify-mcp-settings-volumes.md
            </code>
          </p>
          {data.message && (
            <p className="text-sm text-ink-muted">{data.message}</p>
          )}
        </CardContent>
      </Card>
    );
  }

  if (!data.registrations.length) {
    return (
      <Card className="bg-surface border-rule">
        <CardContent className="p-6 text-ink-muted text-sm">
          לא נמצאו רישומי MCP.
        </CardContent>
      </Card>
    );
  }

  // Group by client
  const groups = new Map<string, typeof data.registrations>();
  for (const r of data.registrations) {
    const arr = groups.get(r.client) ?? [];
    arr.push(r);
    groups.set(r.client, arr);
  }

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-2 text-sm text-ink-muted">
        <Plug className="w-4 h-4" />
        סה"כ {data.registrations.length} רישומים
      </div>
      {[...groups.entries()].map(([client, regs]) => (
        <Card key={client} className="bg-surface border-rule">
          <CardContent className="px-6 py-5">
            <h2 className="text-navy text-lg mb-4 flex items-center gap-2">
              {client}
              <Badge variant="outline" className="text-[0.7rem]">
                {regs.length}
              </Badge>
            </h2>
            <div className="space-y-3">
              {regs.map((r, i) => (
                <div
                  key={`${r.server_name}-${i}`}
                  className="rounded-md border border-rule bg-rule-soft/20 p-4 space-y-2 text-sm"
                >
                  <div className="flex items-center gap-2 mb-1">
                    <code dir="ltr" className="font-mono font-medium text-navy">
                      {r.server_name}
                    </code>
                    <Badge variant="outline" className="text-[0.7rem]" dir="ltr">
                      {r.transport}
                    </Badge>
                  </div>
                  <div className="grid grid-cols-1 md:grid-cols-[100px_1fr] gap-x-3 gap-y-1.5 text-[0.82rem]">
                    <span className="text-ink-muted">command:</span>
                    <code dir="ltr" className="font-mono text-ink break-all">
                      {r.command || ""}
                    </code>
                    <span className="text-ink-muted">args:</span>
                    <code dir="ltr" className="font-mono text-ink break-all">
                      {r.args.length ? JSON.stringify(r.args) : "[]"}
                    </code>
                    <span className="text-ink-muted">cwd:</span>
                    <code dir="ltr" className="font-mono text-ink break-all">
                      {r.cwd || ""}
                    </code>
                    <span className="text-ink-muted">env keys:</span>
                    <div className="flex flex-wrap gap-1">
                      {r.env_keys.length === 0 ? (
                        <span className="text-ink-muted">—</span>
                      ) : (
                        r.env_keys.map((k) => (
                          <Badge
                            key={k}
                            variant="outline"
                            className="text-[0.7rem] font-mono"
                            dir="ltr"
                          >
                            {k}
                          </Badge>
                        ))
                      )}
                    </div>
                  </div>
                </div>
              ))}
            </div>
          </CardContent>
        </Card>
      ))}
    </div>
  );
}
  • Step 2: TypeScript check + lint
cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit && npm run lint

Expected: 0 errors.

  • Step 3: Commit
git add web-ui/src/app/settings/_components/registrations-tab.tsx
git commit -m "feat(settings): implement Registrations tab"

Task 11: Deploy + End-to-End Verification

Files: none (deploy + manual verification)

  • Step 1: Push לכל ה-commits ל-main
cd /home/chaim/legal-ai && git push origin main

Gitea Actions יבנה image ויפעיל Coolify deploy אוטומטית. חכה ~3-4 דקות.

  • Step 2: בדוק שכל ה-endpoints החדשים זמינים
# env list
curl -s https://legal-ai.nautilus.marcusgroup.org/api/settings/mcp/env | jq '.vars | length, .errors'
# tools list
curl -s https://legal-ai.nautilus.marcusgroup.org/api/settings/mcp/tools | jq '.count'
# registrations (יחזיר host_path_unavailable עד שמוסיפים volumes)
curl -s https://legal-ai.nautilus.marcusgroup.org/api/settings/mcp/registrations | jq '.error // "ok"'

Expected:

  • vars: 17, errors: [] (אם Infisical token תקף בקונטיינר)

  • count: ≥ 30 (מספר ה-tools של legal_mcp)

  • registrations.error: "host_path_unavailable" (טבעי בשלב זה)

  • Step 3: בדיקת UI ידנית

ב-https://legal-ai.nautilus.marcusgroup.org/settings:

  1. Tab Paperclip — תוכן קיים, ללא שבירה.
  2. Tab Environment
    • מציג 6 קבוצות (multimodal/rerank/halacha/general/credentials/connection)
    • secrets מוצגים מטושטשים (****xxxx) עם לינק "ערוך ב-Infisical"
    • בדוק drift: ערוך ידנית ב-Coolify UI את MULTIMODAL_TEXT_WEIGHT ל-0.7, מבלי לעדכן ב-Infisical → drift badge אמור להופיע.
  3. Tab Tools — מציג רשימת tools, לחיצה פותחת drawer עם schema.
  4. Tab Registrations — מציג שגיאת host_path_unavailable עם הפניה ל-runbook.
  • Step 4: בדיקת עריכה + redeploy
  1. ערוך HALACHA_AUTO_APPROVE_THRESHOLD מ-0.80 ל-0.85.
  2. לחץ "שמור" → toast "נשמר ב-Infisical".
  3. וודא ב-Infisical UI שהערך אכן 0.85.
  4. לחץ "Redeploy now" → toast "Redeploy הופעל".
  5. חכה ~3-4 דקות ל-Coolify, רענן את הדף → הערך ב-Container אמור להיות 0.85, drift נעלם.
  • Step 5: הוסף volume mounts ב-Coolify (אופציונלי, מאפשר Registrations)

לפי docs/runbooks/coolify-mcp-settings-volumes.md:

  1. Coolify UI → legal-ai → Storages → Add Storage.
  2. הוסף /home/chaim/.claude.json/host/.claude.json (ro).
  3. הוסף /home/chaim/.paperclip/host/.paperclip (ro).
  4. Redeploy.
  5. וודא ב-/settings → Registrations → רישומים מופיעים.
  • Step 6: Commit סופי + סגירה

אם נדרשו תיקונים אחרי בדיקה — commit + push:

git add -p && git commit -m "fix(settings): adjust X based on e2e testing" && git push origin main

Self-Review

Spec coverage:

  • ✓ Tabs (Paperclip / Environment / Tools / Registrations) — Task 7
  • ✓ Catalog + GET env — Tasks 1-2
  • ✓ PATCH env + Redeploy — Task 3
  • ✓ Tools introspection — Task 4
  • ✓ Registrations + Coolify volumes — Task 5
  • ✓ Frontend hooks — Task 6
  • ✓ Environment tab UI — Task 8
  • ✓ Tools tab UI — Task 9
  • ✓ Registrations tab UI — Task 10
  • ✓ Deploy + manual verification — Task 11
  • ✓ Drift detection (normalize_for_compare) — Task 1
  • ✓ Audit log (logger.info) — Task 3
  • ✓ Secrets masked — Task 2 (mask_secret) + Task 1
  • ✓ Whitelist policy — Task 1 (ENV_CATALOG)
  • ✓ Error matrix — handled in endpoints
  • ✓ Runbook ל-volumes — Task 5

Open spec questions (per spec section 7):

  • סביבת Infisical — ברירת מחדל dev, ניתנת לשינוי דרך INFISICAL_ENV env. תוקן ב-Task 2.
  • Path ב-Infisical — ברירת מחדל /legal-ai, ניתנת לשינוי דרך INFISICAL_PATH. תוקן ב-Task 2.
  • "are you sure" dialog — לא נכלל בשלב זה (YAGNI). אם יידרש — task נפרד.

Placeholder scan: אין TODO/TBD בקוד. הערה אחת ב-Task 3 על שונות ב-API של infisical-python בין גרסאות — מציע fallback בקוד, מותר.

Type consistency:

  • McpEnvVar ב-frontend תואם ל-row של backend.
  • McpTool.params_schema הוא unknown — מתאים ל-JSON schema אקראי.
  • useUpdateMcpEnv mutation key מקבל {key, value} — תואם ל-PATCH body shape.

Notes:

  • ה-test framework לא קיים ב-web/ (אין web/tests/). הפלאן מאמץ את הקונבנציה ובודק ידנית עם curl + UI — בסעיף Verification.
  • Frontend אין test framework פעיל ל-web-ui — אותה אסטרטגיה.
  • כל commit הוא atomic ועצמאי, ניתן ל-rollback.