From 796f9d5f9cfdb90b0f6a63de825ce6a1033c65f2 Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 4 May 2026 05:58:53 +0000 Subject: [PATCH] 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) --- .../plans/2026-05-04-mcp-settings-page.md | 2158 +++++++++++++++++ 1 file changed, 2158 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-mcp-settings-page.md diff --git a/docs/superpowers/plans/2026-05-04-mcp-settings-page.md b/docs/superpowers/plans/2026-05-04-mcp-settings-page.md new file mode 100644 index 0000000..a28e620 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-mcp-settings-page.md @@ -0,0 +1,2158 @@ +# דף הגדרות 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** + +```python +# 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: בדיקת ייבוא** + +```bash +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** + +```bash +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): + +```python +from web.mcp_env_catalog import ( + ENV_CATALOG, + EnvSpec, + coerce, + mask_secret, + normalize_for_compare, +) +``` + +הוסף בלוק helpers חדש לפני `# ── Settings: Tag → Company Mappings ──` (לפני שורה 2546): + +```python +# ── 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: + +```python +@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 ולחכות שהקונטיינר יעלה. + +```bash +# Commit + push קודם (Coolify יבנה אוטומטית) — נעשה ב-Step 4 +# הפעלת קונטיינר נעשית בסוף ה-task כשמכינים deploy עם כל הקוד +``` + +- [ ] **Step 4: Commit** + +```bash +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`: + +```python +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: + +```python +@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** + +```bash +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** + +```python +# 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: + +```python +@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** + +```bash +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** + +```bash +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** + +```python +# 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: + +```python +@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** + +```markdown + +# 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`: + +```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("/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** + +```bash +cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit +``` + +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +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** + +```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 ( +
+ + +

+ + חברות ב-Paperclip +

+ {loadingCompanies ? ( + + ) : !companies?.length ? ( +

לא נמצאו חברות

+ ) : ( +
+ {companies.map((c) => ( +
+ {c.name} + + {c.prefix} + +
+ ))} +
+ )} +
+
+ + + +

+ + מיפוי תגיות + + {mappings?.length ?? 0} + +

+ +
+
+ + handleTagInput(e.target.value)} + placeholder="סוג ערר או תגית חופשית" + className="w-[220px]" + /> + + {TAG_SUGGESTIONS.map((s) => ( + + ))} + +
+ +
+ + setTagLabel(e.target.value)} + placeholder="שם לתצוגה" + className="w-[160px]" + /> +
+ +
+ + +
+ + +
+ + {loadingMappings ? ( + + ) : !mappings?.length ? ( +

+ אין מיפויים. הוסף מיפוי כדי שתיקים חדשים ישויכו אוטומטית + לפרויקט בחברה הנכונה. +

+ ) : ( +
+ + + + + + + + + + {mappings.map((m) => ( + + + + + + + ))} + +
TagLabelCompany +
+ + {m.tag} + + {m.tag_label}{m.company_name} + +
+
+ )} +
+
+
+ ); +} +``` + +- [ ] **Step 2: Refactor `page.tsx` ל-Tabs** + +```tsx +// 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 ( + +
+
+ +

הגדרות

+

+ תצורת המערכת, MCP server, ו-Paperclip integration. +

+
+ +
+ + + + + + Paperclip + + + + Environment + + + + Tools + + + + Registrations + + + + + + + + +
+
+ ); +} +``` + +**הערה:** הקובץ ייכשל לקמפל עד שיווצרו 3 הקומפוננטות בטסקים 8-10. בשלב זה יוצרים stubs כדי לקמפל. + +- [ ] **Step 3: צור stubs לקומפוננטות החסרות** + +```tsx +// web-ui/src/app/settings/_components/environment-tab.tsx +export function EnvironmentTab() { return
Environment tab — coming soon
; } +``` + +```tsx +// web-ui/src/app/settings/_components/tools-tab.tsx +export function ToolsTab() { return
Tools tab — coming soon
; } +``` + +```tsx +// web-ui/src/app/settings/_components/registrations-tab.tsx +export function RegistrationsTab() { return
Registrations tab — coming soon
; } +``` + +- [ ] **Step 4: TypeScript check + lint** + +```bash +cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit && npm run lint +``` + +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +```bash +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** + +```tsx +// 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 ( + + + Drift + + ); + } + return ( + + + Synced + + ); +} +``` + +- [ ] **Step 2: Env var editor (control לפי type)** + +```tsx +// 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 ( + onChange(c ? "true" : "false")} + disabled={disabled} + /> + ); + } + + if (spec.enum_values && spec.enum_values.length > 0) { + return ( + + ); + } + + if (spec.type === "int" || spec.type === "float") { + return ( + 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 ( + onChange(e.target.value)} + disabled={disabled} + className="w-[260px] text-start" + dir="ltr" + /> + ); +} +``` + +- [ ] **Step 3: Env var row** + +```tsx +// 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(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 ( +
+
+
+
+ + {spec.key} + + + {spec.type} + + {spec.is_secret && ( + + + secret + + )} + +
+

{spec.description}

+
+
+ +
+
+ Infisical: + {spec.is_editable ? ( + + ) : ( + + {spec.infisical_value ?? — לא מוגדר —} + + )} +
+
+ Container: + + {spec.container_value ?? — לא מוגדר —} + +
+
+ +
+ {!spec.is_editable && ( + + ערוך ב-Infisical + + + )} + {spec.is_editable && ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 4: Environment tab** + +```tsx +// 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 = { + 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(); + const m = new Map(); + 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 ; + if (error) { + return ( + + + + שגיאה בטעינת env vars: {error.message} + + + ); + } + if (!data) return null; + + const driftCount = data.vars.filter((v) => v.drift).length; + + return ( +
+ + +
+ + Infisical: {data.infisical_environment} + + + Path: {data.infisical_path} + + {driftCount > 0 && ( + + {driftCount} drift + + )} + {data.errors.length > 0 && ( + + {data.errors.join(", ")} + + )} +
+ +
+
+ + {CATEGORY_ORDER.map((cat) => { + const vars = grouped.get(cat); + if (!vars || vars.length === 0) return null; + return ( + + +

+ {CATEGORY_LABELS[cat]} + + {vars.length} + +

+
+ {vars.map((v) => ( + setPendingRedeploy(true)} + /> + ))} +
+
+
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 5: TypeScript check** + +```bash +cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit && npm run lint +``` + +Expected: 0 errors. אם חסר רכיב `Switch` ב-shadcn — להריץ: +```bash +cd web-ui && npx shadcn@latest add switch +``` + +- [ ] **Step 6: Commit** + +```bash +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** + +```tsx +// 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 ( + + + {tool && ( + <> + + + {tool.name} + + {tool.description || "—"} + +
+
+
+ Module +
+ + {tool.module} + +
+
+
+ Source +
+ + {tool.source_location || "—"} + +
+
+
+ Parameters Schema +
+
+                  {JSON.stringify(tool.params_schema, null, 2)}
+                
+
+
+ + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Tools tab** + +```tsx +// 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(null); + const [open, setOpen] = useState(false); + + const grouped = useMemo(() => { + if (!data?.tools) return new Map(); + const m = new Map(); + 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 ; + if (error) { + return ( + + + + שגיאה בטעינת tools: {error.message} + + + ); + } + if (!data) return null; + + return ( +
+
+ + סה"כ {data.count} tools +
+ {[...grouped.entries()].sort().map(([mod, tools]) => ( + + +

+ {mod} + + {tools.length} + +

+
+ {tools.map((t) => ( + + ))} +
+
+
+ ))} + +
+ ); +} +``` + +- [ ] **Step 3: TypeScript check** + +```bash +cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit && npm run lint +``` + +Expected: 0 errors. אם חסר `Sheet` ב-shadcn: +```bash +npx shadcn@latest add sheet +``` + +- [ ] **Step 4: Commit** + +```bash +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** + +```tsx +// 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 ; + if (error) { + return ( + + + + שגיאה: {error.message} + + + ); + } + if (!data) return null; + + if (data.error === "host_path_unavailable") { + return ( + + +
+ + תיקיית /host לא זמינה בקונטיינר +
+

+ כדי להציג רישומי MCP, יש להוסיף volume mounts ב-Coolify. + ראה runbook ב- + + docs/runbooks/coolify-mcp-settings-volumes.md + +

+ {data.message && ( +

{data.message}

+ )} +
+
+ ); + } + + if (!data.registrations.length) { + return ( + + + לא נמצאו רישומי MCP. + + + ); + } + + // Group by client + const groups = new Map(); + for (const r of data.registrations) { + const arr = groups.get(r.client) ?? []; + arr.push(r); + groups.set(r.client, arr); + } + + return ( +
+
+ + סה"כ {data.registrations.length} רישומים +
+ {[...groups.entries()].map(([client, regs]) => ( + + +

+ {client} + + {regs.length} + +

+
+ {regs.map((r, i) => ( +
+
+ + {r.server_name} + + + {r.transport} + +
+
+ command: + + {r.command || "—"} + + args: + + {r.args.length ? JSON.stringify(r.args) : "[]"} + + cwd: + + {r.cwd || "—"} + + env keys: +
+ {r.env_keys.length === 0 ? ( + + ) : ( + r.env_keys.map((k) => ( + + {k} + + )) + )} +
+
+
+ ))} +
+
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 2: TypeScript check + lint** + +```bash +cd /home/chaim/legal-ai/web-ui && npx tsc --noEmit && npm run lint +``` + +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +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** + +```bash +cd /home/chaim/legal-ai && git push origin main +``` + +Gitea Actions יבנה image ויפעיל Coolify deploy אוטומטית. +חכה ~3-4 דקות. + +- [ ] **Step 2: בדוק שכל ה-endpoints החדשים זמינים** + +```bash +# 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: +```bash +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.