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>
2159 lines
70 KiB
Markdown
2159 lines
70 KiB
Markdown
# דף הגדרות 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.
|