# דף הגדרות 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) => ( ))}
Tag Label Company
{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.