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

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

2159 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# דף הגדרות 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
<!-- docs/runbooks/coolify-mcp-settings-volumes.md -->
# Coolify Volume Mounts ל-MCP Settings Page
## רקע
טאב **Registrations** בדף `/settings` קורא רישומי MCP מתוך:
- `~/.claude.json` (host)
- `~/.paperclip/instances/*/mcp.json` (host)
הקונטיינר של legal-ai חייב גישת קריאה לקבצים אלה דרך volume mounts.
בלי המאונט, ה-endpoint יחזיר `error: "host_path_unavailable"` והטאב יציג הודעת אי-זמינות.
## הוראות
1. פתח Coolify UI: `http://158.178.131.193:8000`.
2. נווט לאפליקציה: legal-ai (UUID `gyjo0mtw2c42ej3xxvbz8zio`).
3. לשונית **Storages****Add Storage**.
4. הוסף שני mounts:
| Source path (host) | Destination path (container) | Mode |
|---|---|---|
| `/home/chaim/.claude.json` | `/host/.claude.json` | `ro` |
| `/home/chaim/.paperclip` | `/host/.paperclip` | `ro` |
5. שמור ולחץ **Redeploy**.
## אימות
אחרי ה-redeploy:
```bash
curl -s https://legal-ai.nautilus.marcusgroup.org/api/settings/mcp/registrations | jq
```
צריך להחזיר `"error": null` ורשימת רישומים.
## הערה אבטחה
המאונטים הם read-only. ה-endpoint לא מחזיר ערכי env (רק שמות keys),
ולא מאפשר לעדכן את הקבצים.
```
- [ ] **Step 4: Commit**
```bash
git add web/mcp_registrations.py web/app.py docs/runbooks/coolify-mcp-settings-volumes.md
git commit -m "feat(settings): add MCP registrations endpoint + Coolify volume runbook"
```
---
## Task 6: Frontend — API Hooks
**Files:**
- Modify: `web-ui/src/lib/api/settings.ts`
- [ ] **Step 1: הוסף types ו-hooks חדשים**
הוסף בסוף `web-ui/src/lib/api/settings.ts`:
```ts
// ── MCP Settings ────────────────────────────────────────────────
export type EnvCategory =
| "multimodal"
| "rerank"
| "halacha"
| "credentials"
| "connection"
| "general";
export type EnvType = "bool" | "int" | "float" | "string";
export type McpEnvVar = {
key: string;
category: EnvCategory;
type: EnvType;
description: string;
is_secret: boolean;
is_editable: boolean;
default: unknown;
min: number | null;
max: number | null;
enum_values: string[] | null;
infisical_value: string | null;
container_value: string | null;
drift: boolean;
};
export type McpEnvResponse = {
vars: McpEnvVar[];
infisical_environment: string;
infisical_project_id: string;
infisical_path: string;
coolify_app_uuid: string;
errors: string[];
};
export type McpTool = {
name: string;
description: string;
params_schema: unknown;
module: string;
source_location: string;
};
export type McpRegistration = {
client: string;
server_name: string;
command: string;
args: string[];
cwd: string;
env_keys: string[];
transport: string;
};
export function useMcpEnv() {
return useQuery({
queryKey: ["settings", "mcp-env"] as const,
queryFn: ({ signal }) =>
apiRequest<McpEnvResponse>("/api/settings/mcp/env", { signal }),
staleTime: 5_000,
});
}
export function useUpdateMcpEnv() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
apiRequest<{
ok: boolean;
key: string;
saved_value: string;
requires_redeploy: boolean;
message: string;
}>(`/api/settings/mcp/env/${encodeURIComponent(key)}`, {
method: "PATCH",
body: { value },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "mcp-env"] }),
});
}
export function useMcpRedeploy() {
return useMutation({
mutationFn: () =>
apiRequest<{ ok: boolean; deployment_uuid: string | null; message: string }>(
"/api/settings/mcp/env/redeploy",
{ method: "POST" },
),
});
}
export function useMcpTools() {
return useQuery({
queryKey: ["settings", "mcp-tools"] as const,
queryFn: ({ signal }) =>
apiRequest<{ tools: McpTool[]; count: number }>("/api/settings/mcp/tools", {
signal,
}),
staleTime: 60_000,
});
}
export function useMcpRegistrations() {
return useQuery({
queryKey: ["settings", "mcp-registrations"] as const,
queryFn: ({ signal }) =>
apiRequest<{
registrations: McpRegistration[];
error: string | null;
message?: string;
}>("/api/settings/mcp/registrations", { signal }),
staleTime: 60_000,
});
}
```
- [ ] **Step 2: TypeScript check**
```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 (
<div className="space-y-6">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4" />
חברות ב-Paperclip
</h2>
{loadingCompanies ? (
<Skeleton className="h-12 w-full" />
) : !companies?.length ? (
<p className="text-ink-muted text-sm">לא נמצאו חברות</p>
) : (
<div className="flex flex-wrap gap-3">
{companies.map((c) => (
<div
key={c.id}
className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5"
>
<span className="text-sm font-medium text-ink">{c.name}</span>
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{c.prefix}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
<Tags className="w-4 h-4" />
מיפוי תגיות
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{mappings?.length ?? 0}
</Badge>
</h2>
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
<div className="flex flex-col gap-1.5 min-w-[180px]">
<label className="text-[0.72rem] text-ink-muted">תגית</label>
<Input
list="tag-suggestions"
value={tag}
onChange={(e) => handleTagInput(e.target.value)}
placeholder="סוג ערר או תגית חופשית"
className="w-[220px]"
/>
<datalist id="tag-suggestions">
{TAG_SUGGESTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</datalist>
</div>
<div className="flex flex-col gap-1.5 min-w-[140px]">
<label className="text-[0.72rem] text-ink-muted">תווית</label>
<Input
value={tagLabel}
onChange={(e) => setTagLabel(e.target.value)}
placeholder="שם לתצוגה"
className="w-[160px]"
/>
</div>
<div className="flex flex-col gap-1.5 min-w-[200px]">
<label className="text-[0.72rem] text-ink-muted">
חברה ב-Paperclip
</label>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="בחר חברה" />
</SelectTrigger>
<SelectContent>
{companies?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name} ({c.prefix})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleAdd}
disabled={addMapping.isPending || !tag || !companyId}
size="default"
>
<Plus className="w-4 h-4" data-icon="inline-start" />
{addMapping.isPending ? "שומר..." : "הוסף מיפוי"}
</Button>
</div>
{loadingMappings ? (
<Skeleton className="h-32 w-full" />
) : !mappings?.length ? (
<p className="text-ink-muted text-sm">
אין מיפויים. הוסף מיפוי כדי שתיקים חדשים ישויכו אוטומטית
לפרויקט בחברה הנכונה.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-rule text-ink-muted text-[0.72rem] uppercase tracking-wider">
<th className="text-start py-2 px-3 font-medium">Tag</th>
<th className="text-start py-2 px-3 font-medium">Label</th>
<th className="text-start py-2 px-3 font-medium">Company</th>
<th className="py-2 px-3 w-12" />
</tr>
</thead>
<tbody>
{mappings.map((m) => (
<tr
key={m.id}
className="border-b border-rule/60 hover:bg-rule-soft/40 transition-colors"
>
<td className="py-2.5 px-3">
<Badge variant="outline" className="text-[0.75rem] font-mono">
{m.tag}
</Badge>
</td>
<td className="py-2.5 px-3 text-ink">{m.tag_label}</td>
<td className="py-2.5 px-3 text-ink">{m.company_name}</td>
<td className="py-2.5 px-3">
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDelete(m.id, m.tag)}
disabled={deleteMapping.isPending}
title="מחק מיפוי"
>
<Trash2 className="w-3.5 h-3.5 text-danger" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
```
- [ ] **Step 2: Refactor `page.tsx` ל-Tabs**
```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 (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">
בית
</Link>
<span aria-hidden> · </span>
<span className="text-navy">הגדרות</span>
</nav>
<h1 className="text-navy mb-0">הגדרות</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
תצורת המערכת, MCP server, ו-Paperclip integration.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Tabs defaultValue="paperclip" className="space-y-4">
<TabsList>
<TabsTrigger value="paperclip">
<Building2 className="w-4 h-4" data-icon="inline-start" />
Paperclip
</TabsTrigger>
<TabsTrigger value="environment">
<Server className="w-4 h-4" data-icon="inline-start" />
Environment
</TabsTrigger>
<TabsTrigger value="tools">
<Wrench className="w-4 h-4" data-icon="inline-start" />
Tools
</TabsTrigger>
<TabsTrigger value="registrations">
<Plug className="w-4 h-4" data-icon="inline-start" />
Registrations
</TabsTrigger>
</TabsList>
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
<TabsContent value="tools"><ToolsTab /></TabsContent>
<TabsContent value="registrations"><RegistrationsTab /></TabsContent>
</Tabs>
</section>
</AppShell>
);
}
```
**הערה:** הקובץ ייכשל לקמפל עד שיווצרו 3 הקומפוננטות בטסקים 8-10. בשלב זה יוצרים stubs כדי לקמפל.
- [ ] **Step 3: צור stubs לקומפוננטות החסרות**
```tsx
// web-ui/src/app/settings/_components/environment-tab.tsx
export function EnvironmentTab() { return <div>Environment tab coming soon</div>; }
```
```tsx
// web-ui/src/app/settings/_components/tools-tab.tsx
export function ToolsTab() { return <div>Tools tab coming soon</div>; }
```
```tsx
// web-ui/src/app/settings/_components/registrations-tab.tsx
export function RegistrationsTab() { return <div>Registrations tab coming soon</div>; }
```
- [ ] **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 (
<Badge variant="outline" className="text-warn border-warn/40 gap-1">
<AlertTriangle className="w-3 h-3" />
Drift
</Badge>
);
}
return (
<Badge variant="outline" className="text-success border-success/40 gap-1">
<CheckCircle2 className="w-3 h-3" />
Synced
</Badge>
);
}
```
- [ ] **Step 2: Env var editor (control לפי type)**
```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 (
<Switch
checked={checked}
onCheckedChange={(c) => onChange(c ? "true" : "false")}
disabled={disabled}
/>
);
}
if (spec.enum_values && spec.enum_values.length > 0) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{spec.enum_values.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (spec.type === "int" || spec.type === "float") {
return (
<Input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
min={spec.min ?? undefined}
max={spec.max ?? undefined}
step={spec.type === "float" ? "0.01" : "1"}
disabled={disabled}
className="w-[160px] text-start"
dir="ltr"
/>
);
}
return (
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-[260px] text-start"
dir="ltr"
/>
);
}
```
- [ ] **Step 3: Env var row**
```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<string>(spec.infisical_value ?? "");
const update = useUpdateMcpEnv();
const dirty = draft !== (spec.infisical_value ?? "");
function handleSave() {
update.mutate(
{ key: spec.key, value: draft },
{
onSuccess: (res) => {
toast.success(res.message);
onPendingRedeploy();
},
onError: (err) => toast.error(`שגיאה: ${err.message}`),
},
);
}
const infisicalUrl =
`https://secret.dev.marcus-law.co.il/project/${infisicalProjectId}/secrets/overview?env=${infisicalEnv}`;
return (
<div className="rounded-md border border-rule p-4 bg-rule-soft/20 hover:bg-rule-soft/40 transition-colors">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="font-mono text-sm font-medium text-navy" dir="ltr">
{spec.key}
</code>
<Badge variant="outline" className="text-[0.7rem]">
{spec.type}
</Badge>
{spec.is_secret && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40 gap-1">
<Lock className="w-3 h-3" />
secret
</Badge>
)}
<DriftBadge drift={spec.drift} />
</div>
<p className="text-sm text-ink-muted mt-1">{spec.description}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-[0.72rem] text-ink-muted w-20">Infisical:</span>
{spec.is_editable ? (
<EnvVarEditor
spec={spec}
value={draft}
onChange={setDraft}
disabled={update.isPending}
/>
) : (
<span className="font-mono text-ink" dir="ltr">
{spec.infisical_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[0.72rem] text-ink-muted w-20">Container:</span>
<span className="font-mono text-ink" dir="ltr">
{spec.container_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-3">
{!spec.is_editable && (
<a
href={infisicalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[0.78rem] text-gold-deep hover:underline flex items-center gap-1"
>
ערוך ב-Infisical
<ExternalLink className="w-3 h-3" />
</a>
)}
{spec.is_editable && (
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || update.isPending}
>
<Save className="w-3.5 h-3.5" data-icon="inline-start" />
{update.isPending ? "שומר..." : "שמור"}
</Button>
)}
</div>
</div>
);
}
```
- [ ] **Step 4: Environment tab**
```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<EnvCategory, string> = {
multimodal: "Multimodal",
rerank: "Rerank",
halacha: "Halacha",
general: "כללי",
credentials: "אישורים",
connection: "חיבורים",
};
const CATEGORY_ORDER: EnvCategory[] = [
"multimodal", "rerank", "halacha", "general", "credentials", "connection",
];
export function EnvironmentTab() {
const { data, isPending, error } = useMcpEnv();
const redeploy = useMcpRedeploy();
const [pendingRedeploy, setPendingRedeploy] = useState(false);
const grouped = useMemo(() => {
if (!data?.vars) return new Map<EnvCategory, McpEnvVar[]>();
const m = new Map<EnvCategory, McpEnvVar[]>();
for (const v of data.vars) {
const arr = m.get(v.category) ?? [];
arr.push(v);
m.set(v.category, arr);
}
return m;
}, [data]);
function handleRedeploy() {
redeploy.mutate(undefined, {
onSuccess: (res) => {
toast.success(res.message);
setPendingRedeploy(false);
},
onError: (err) => toast.error(`Redeploy נכשל: ${err.message}`),
});
}
if (isPending) return <Skeleton className="h-96 w-full" />;
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה בטעינת env vars: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
const driftCount = data.vars.filter((v) => v.drift).length;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3 flex-wrap text-sm">
<Badge variant="outline">
Infisical: <code dir="ltr" className="ms-1">{data.infisical_environment}</code>
</Badge>
<Badge variant="outline">
Path: <code dir="ltr" className="ms-1">{data.infisical_path}</code>
</Badge>
{driftCount > 0 && (
<Badge variant="outline" className="text-warn border-warn/40">
{driftCount} drift
</Badge>
)}
{data.errors.length > 0 && (
<Badge variant="outline" className="text-danger border-danger/40">
{data.errors.join(", ")}
</Badge>
)}
</div>
<Button
onClick={handleRedeploy}
disabled={redeploy.isPending}
variant={pendingRedeploy ? "default" : "outline"}
size="sm"
>
<RefreshCw className={redeploy.isPending ? "w-3.5 h-3.5 animate-spin" : "w-3.5 h-3.5"} data-icon="inline-start" />
{redeploy.isPending ? "Redeploying..." : "Redeploy now"}
</Button>
</CardContent>
</Card>
{CATEGORY_ORDER.map((cat) => {
const vars = grouped.get(cat);
if (!vars || vars.length === 0) return null;
return (
<Card key={cat} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
{CATEGORY_LABELS[cat]}
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{vars.length}
</Badge>
</h2>
<div className="space-y-3">
{vars.map((v) => (
<EnvVarRow
key={v.key}
spec={v}
infisicalProjectId={data.infisical_project_id}
infisicalEnv={data.infisical_environment}
onPendingRedeploy={() => setPendingRedeploy(true)}
/>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
```
- [ ] **Step 5: TypeScript check**
```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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="sm:max-w-xl overflow-y-auto">
{tool && (
<>
<SheetHeader>
<SheetTitle dir="ltr" className="font-mono text-navy">
{tool.name}
</SheetTitle>
<SheetDescription>{tool.description || "—"}</SheetDescription>
</SheetHeader>
<div className="space-y-4 mt-4 px-4 pb-6">
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Module
</div>
<Badge variant="outline" className="font-mono" dir="ltr">
{tool.module}
</Badge>
</div>
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Source
</div>
<code dir="ltr" className="text-xs text-ink break-all">
{tool.source_location || "—"}
</code>
</div>
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Parameters Schema
</div>
<pre
dir="ltr"
className="text-xs bg-rule-soft/40 border border-rule rounded-md p-3 overflow-x-auto"
>
{JSON.stringify(tool.params_schema, null, 2)}
</pre>
</div>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}
```
- [ ] **Step 2: Tools tab**
```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<McpTool | null>(null);
const [open, setOpen] = useState(false);
const grouped = useMemo(() => {
if (!data?.tools) return new Map<string, McpTool[]>();
const m = new Map<string, McpTool[]>();
for (const t of data.tools) {
const mod = t.module.split(".").pop() || "other";
const arr = m.get(mod) ?? [];
arr.push(t);
m.set(mod, arr);
}
return m;
}, [data]);
if (isPending) return <Skeleton className="h-96 w-full" />;
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה בטעינת tools: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-muted">
<Wrench className="w-4 h-4" />
סה"כ {data.count} tools
</div>
{[...grouped.entries()].sort().map(([mod, tools]) => (
<Card key={mod} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
<code dir="ltr">{mod}</code>
<Badge variant="outline" className="text-[0.7rem]">
{tools.length}
</Badge>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{tools.map((t) => (
<button
key={t.name}
onClick={() => {
setSelected(t);
setOpen(true);
}}
className="text-start rounded-md border border-rule px-3 py-2 hover:bg-rule-soft/40 transition-colors"
>
<code dir="ltr" className="font-mono text-sm text-navy">
{t.name}
</code>
{t.description && (
<p className="text-[0.78rem] text-ink-muted mt-0.5 line-clamp-2">
{t.description}
</p>
)}
</button>
))}
</div>
</CardContent>
</Card>
))}
<ToolDetailDrawer tool={selected} open={open} onOpenChange={setOpen} />
</div>
);
}
```
- [ ] **Step 3: TypeScript check**
```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 <Skeleton className="h-64 w-full" />;
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
if (data.error === "host_path_unavailable") {
return (
<Card className="bg-surface border-warn/40">
<CardContent className="p-6">
<div className="flex items-center gap-3 text-warn mb-2">
<AlertCircle className="w-5 h-5" />
<span className="font-medium">תיקיית /host לא זמינה בקונטיינר</span>
</div>
<p className="text-sm text-ink-muted mb-2">
כדי להציג רישומי MCP, יש להוסיף volume mounts ב-Coolify.
ראה runbook ב-
<code dir="ltr" className="mx-1">
docs/runbooks/coolify-mcp-settings-volumes.md
</code>
</p>
{data.message && (
<p className="text-sm text-ink-muted">{data.message}</p>
)}
</CardContent>
</Card>
);
}
if (!data.registrations.length) {
return (
<Card className="bg-surface border-rule">
<CardContent className="p-6 text-ink-muted text-sm">
לא נמצאו רישומי MCP.
</CardContent>
</Card>
);
}
// Group by client
const groups = new Map<string, typeof data.registrations>();
for (const r of data.registrations) {
const arr = groups.get(r.client) ?? [];
arr.push(r);
groups.set(r.client, arr);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-muted">
<Plug className="w-4 h-4" />
סה"כ {data.registrations.length} רישומים
</div>
{[...groups.entries()].map(([client, regs]) => (
<Card key={client} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
{client}
<Badge variant="outline" className="text-[0.7rem]">
{regs.length}
</Badge>
</h2>
<div className="space-y-3">
{regs.map((r, i) => (
<div
key={`${r.server_name}-${i}`}
className="rounded-md border border-rule bg-rule-soft/20 p-4 space-y-2 text-sm"
>
<div className="flex items-center gap-2 mb-1">
<code dir="ltr" className="font-mono font-medium text-navy">
{r.server_name}
</code>
<Badge variant="outline" className="text-[0.7rem]" dir="ltr">
{r.transport}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-[100px_1fr] gap-x-3 gap-y-1.5 text-[0.82rem]">
<span className="text-ink-muted">command:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.command || ""}
</code>
<span className="text-ink-muted">args:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.args.length ? JSON.stringify(r.args) : "[]"}
</code>
<span className="text-ink-muted">cwd:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.cwd || ""}
</code>
<span className="text-ink-muted">env keys:</span>
<div className="flex flex-wrap gap-1">
{r.env_keys.length === 0 ? (
<span className="text-ink-muted">—</span>
) : (
r.env_keys.map((k) => (
<Badge
key={k}
variant="outline"
className="text-[0.7rem] font-mono"
dir="ltr"
>
{k}
</Badge>
))
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
);
}
```
- [ ] **Step 2: TypeScript check + lint**
```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.