diff --git a/scripts/pi2-backup/auto-git-backup.sh b/scripts/pi2-backup/auto-git-backup.sh index 15087e4..327131c 100755 --- a/scripts/pi2-backup/auto-git-backup.sh +++ b/scripts/pi2-backup/auto-git-backup.sh @@ -86,6 +86,14 @@ else echo "⚠️ Impossibile scaricare Keepalived conf da Pi1." fi +# D. Sorgente Loogle MCP (app completa: qui vive solo su Pi1, fuori da git) +# .env resta escluso: contiene JWT secret e token Paperless/Gitea/OpenAI. +mkdir -p "$REPO_DIR/services/loogle-mcp" +rsync -av --delete -e "ssh -q" \ + --exclude='.env' --exclude='data/' --exclude='__pycache__/' --exclude='*.pyc' --exclude='.cursor/' \ + "$PI1_USER@$PI1_IP:/home/daniely/docker/loogle-mcp/" "$REPO_DIR/services/loogle-mcp/" \ + || echo "⚠️ rsync sorgente MCP fallito (ignorato)" + # 4. GIT PUSH # ----------- if [[ -n $(git status --porcelain) ]]; then diff --git a/services/loogle-mcp/.env.example b/services/loogle-mcp/.env.example new file mode 100644 index 0000000..4cd98cb --- /dev/null +++ b/services/loogle-mcp/.env.example @@ -0,0 +1,81 @@ +# Loogle MCP Hub — copia in .env e personalizza +MCP_BASE_URL=https://mcp.loogle.it +MCP_PORT=8700 +MCP_JWT_SECRET=change-me-to-a-long-random-string +MCP_OAUTH_CLIENT_ID=loogle-mcp-public + +# Paperless API — vedi docs/PAPERLESS-TOKEN.md +PAPERLESS_URL=https://docs.loogle.it +# Fallback admin (opzionale se usi token per utente sotto) +PAPERLESS_API_TOKEN= +PAPERLESS_API_TOKEN_DANIELE= +PAPERLESS_API_TOKEN_LUCIA= +PAPERLESS_API_TOKEN_DAVIDE= +PAPERLESS_API_TOKEN_LUCA= + +# Gitea API — vedi docs/GITEA-TOKEN.md +GITEA_URL=https://git.loogle.it +# Opzionale: URL API raggiungibile dal container MCP (es. http://192.168.128.81:3002) +# GITEA_API_URL= +GITEA_API_TOKEN= +GITEA_API_TOKEN_DANIELE= +GITEA_API_TOKEN_LUCIA= +GITEA_API_TOKEN_DAVIDE= +GITEA_API_TOKEN_LUCA= + +# Qdrant (container locale o remoto) +QDRANT_URL=http://192.168.128.100:6333 + +# Embedding via Ollama su DS920+ +OLLAMA_URL=http://192.168.128.100:11434 +OLLAMA_EMBED_MODEL=nomic-embed-text + +# Fallback cloud embedding (opzionale) +OPENAI_API_KEY= +OPENAI_EMBED_MODEL=text-embedding-3-small + +# Indicizzazione +INDEXER_INTERVAL_MINUTES=30 + +# Thermal gate DS920 — evita overheating durante embedding +THERMAL_GATE_ENABLED=yes +THERMAL_TEMP_SOFT_C=58 +THERMAL_TEMP_HARD_C=70 +THERMAL_CPU_TARGET_PCT=40 +THERMAL_POLL_S=30 +THERMAL_RESUME_MARGIN_C=2 +DS920_THERMAL_URL=http://192.168.128.100:9191/thermal +OLLAMA_NUM_THREAD=1 +OLLAMA_EMBED_DELAY_S=10 +OLLAMA_EMBED_COOL_DELAY_S=3 + +# RAG Gitea (P3) — indicizza markdown/codice da git.loogle.it +GITEA_INDEX_ENABLED=yes +# Opzionale: limita repo per utente (CSV). Vuoto = tutti i repo accessibili via API +# GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts +# GITEA_INDEX_REPOS_DAVIDE=davide/progetti +# GITEA_INDEX_REPOS_LUCA=luca/progetti +GITEA_INDEX_MAX_FILES_PER_REPO=150 +GITEA_INDEX_MAX_FILE_BYTES=120000 + +# P5 — Loogle Casa + Home Assistant (read-only) +LOOGLE_CASA_URL=https://casa.loogle.it +# LOOGLE_CASA_API_URL=http://192.168.128.81:5602 +LOOGLE_CASA_PASSWORD_DANIELE= +LOOGLE_CASA_PASSWORD_LUCIA= +LOOGLE_CASA_PASSWORD_DAVIDE= +LOOGLE_CASA_PASSWORD_LUCA= +HA_URL=https://ha.loogle.it +# HA_API_URL=http://192.168.128.81:8123 +HA_TOKEN= + +# P6 — Irrigazione + Turni (read-only) +IRRIGAZIONE_URL=https://irri.loogle.it +# IRRIGAZIONE_API_URL=http://192.168.128.81:5601 +IRRIGAZIONE_PASSWORD_DANIELE= +TURNI_URL=https://turni.loogle.it +TURNI_PASSWORD_DANIELE= + +# P7 — RAG Irrigazione + Turni +APPS_INDEX_ENABLED=yes +APPS_INDEX_USER=daniele diff --git a/services/loogle-mcp/.gitignore b/services/loogle-mcp/.gitignore new file mode 100644 index 0000000..5b70d24 --- /dev/null +++ b/services/loogle-mcp/.gitignore @@ -0,0 +1,4 @@ +data/ +.env +*.pyc +__pycache__/ diff --git a/services/loogle-mcp/Dockerfile b/services/loogle-mcp/Dockerfile new file mode 100644 index 0000000..a2b0f19 --- /dev/null +++ b/services/loogle-mcp/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 TZ=Europe/Rome + +RUN apt-get update && apt-get install -y --no-install-recommends curl openssh-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /srv + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY worker ./worker +COPY scripts ./scripts +COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +ENV PYTHONPATH=/srv +VOLUME /data +EXPOSE 8700 + +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8700"] diff --git a/services/loogle-mcp/README.md b/services/loogle-mcp/README.md new file mode 100644 index 0000000..66ee122 --- /dev/null +++ b/services/loogle-mcp/README.md @@ -0,0 +1,23 @@ +# Loogle MCP Hub + +Piattaforma MCP multi-utente per homelab LOOGLE.IT. + +## Documentazione + +| Documento | Per chi | +|-----------|---------| +| [ARCHITETTURA.md](docs/ARCHITETTURA.md) | Architettura, parametri, DNS, NPM, env | +| [GUIDA-ADMIN.md](docs/GUIDA-ADMIN.md) | Daniele — gestione e uso avanzato | +| [GUIDA-UTENTI.md](docs/GUIDA-UTENTI.md) | Lucia, Davide, Luca — client AI e uso quotidiano | +| [ONBOARDING.md](docs/ONBOARDING.md) | Dettaglio tecnico connector MCP | + +## URL + +- MCP: https://mcp.loogle.it/mcp +- Dashboard: https://mcp.loogle.it/dashboard + +## Setup DNS + NPM + +```bash +sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh +``` diff --git a/services/loogle-mcp/app/__init__.py b/services/loogle-mcp/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/loogle-mcp/app/audit.py b/services/loogle-mcp/app/audit.py new file mode 100644 index 0000000..6fd55da --- /dev/null +++ b/services/loogle-mcp/app/audit.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Audit log for MCP tool invocations.""" + +import json +from typing import Optional + +from .db import get_conn + + +def log_tool(username: str, tool_name: str, resource_id: Optional[str] = None, detail: Optional[dict] = None) -> None: + conn = get_conn() + conn.execute( + "INSERT INTO audit_log(username,tool_name,resource_id,detail) VALUES (?,?,?,?)", + (username, tool_name, resource_id, json.dumps(detail or {}, ensure_ascii=False)), + ) + conn.execute( + "DELETE FROM audit_log WHERE id NOT IN " + "(SELECT id FROM audit_log ORDER BY id DESC LIMIT 5000)" + ) + conn.commit() + + +def list_audit(limit: int = 100, username: Optional[str] = None) -> list: + conn = get_conn() + if username: + rows = conn.execute( + "SELECT * FROM audit_log WHERE username=? ORDER BY id DESC LIMIT ?", + (username, limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] diff --git a/services/loogle-mcp/app/auth.py b/services/loogle-mcp/app/auth.py new file mode 100644 index 0000000..1f335de --- /dev/null +++ b/services/loogle-mcp/app/auth.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +"""Autenticazione utenti famiglia — pattern Loogle Casa.""" + +import base64 +import datetime +import hashlib +import hmac +import os +import secrets +import time +from typing import Optional + +from fastapi import HTTPException, Request + +from .db import get_conn + +SESSION_DAYS = 30 +PBKDF2_ITER = 240_000 +MAX_ATTEMPTS = 8 +WINDOW_S = 600 +_attempts: dict = {} + +FAMILY_USERS = ( + ("daniele", True), + ("lucia", False), + ("davide", False), + ("luca", False), +) + +DEFAULT_SCOPES = ( + "context:read context:write knowledge:read knowledge:write gitea:read gitea:write" +) + + +def hash_password(password: str) -> str: + salt = os.urandom(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITER) + return "pbkdf2$%d$%s$%s" % ( + PBKDF2_ITER, + base64.b64encode(salt).decode(), + base64.b64encode(dk).decode(), + ) + + +def verify_password(password: str, stored: str) -> bool: + try: + _, iters, salt_b64, dk_b64 = stored.split("$") + salt = base64.b64decode(salt_b64) + expected = base64.b64decode(dk_b64) + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(iters)) + return hmac.compare_digest(dk, expected) + except Exception: + return False + + +def ensure_family_users() -> None: + conn = get_conn() + for username, is_admin in FAMILY_USERS: + row = conn.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone() + if row: + continue + conn.execute( + "INSERT INTO users(username,password_hash,is_admin,must_change_password)" + " VALUES (?,?,?,1)", + (username, hash_password(username), 1 if is_admin else 0), + ) + conn.commit() + + +def throttle(ip: str) -> None: + now = time.time() + hist = [t for t in _attempts.get(ip, []) if now - t < WINDOW_S] + _attempts[ip] = hist + if len(hist) >= MAX_ATTEMPTS: + raise HTTPException(429, "Troppi tentativi: riprova tra qualche minuto") + + +def record_attempt(ip: str) -> None: + _attempts.setdefault(ip, []).append(time.time()) + + +def authenticate(username: str, password: str) -> Optional[dict]: + conn = get_conn() + row = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone() + if not row or not verify_password(password, row["password_hash"]): + return None + return dict(row) + + +def get_user_by_id(user_id: int) -> Optional[dict]: + row = get_conn().execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + return dict(row) if row else None + + +def get_user_by_username(username: str) -> Optional[dict]: + row = get_conn().execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + return dict(row) if row else None + + +def change_password(user_id: int, old_password: str, new_password: str) -> bool: + row = get_conn().execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone() + if not row or not verify_password(old_password, row["password_hash"]): + return False + conn = get_conn() + conn.execute( + "UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", + (hash_password(new_password), user_id), + ) + conn.commit() + return True + + +def current_user_from_cookie(request: Request) -> dict: + token = request.cookies.get("mcp_session", "") + if not token: + raise HTTPException(401, "Non autenticato") + row = get_conn().execute( + "SELECT u.id,u.username,u.is_admin,u.must_change_password" + " FROM sessions s JOIN users u ON u.id=s.user_id" + " WHERE s.token=? AND s.expires_at > datetime('now')", + (token,), + ).fetchone() + if not row: + raise HTTPException(401, "Sessione scaduta") + return dict(row) + + +def require_admin(request: Request) -> dict: + user = current_user_from_cookie(request) + if not user["is_admin"]: + raise HTTPException(403, "Riservato all'amministratore") + return user + + +def ensure_sessions_table() -> None: + get_conn().execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ) + """ + ) + get_conn().commit() diff --git a/services/loogle-mcp/app/context/__init__.py b/services/loogle-mcp/app/context/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/loogle-mcp/app/context/gitea_link.py b/services/loogle-mcp/app/context/gitea_link.py new file mode 100644 index 0000000..fa30d33 --- /dev/null +++ b/services/loogle-mcp/app/context/gitea_link.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""Collegamento progetti MCP ↔ repository Gitea.""" + +from __future__ import annotations + +import logging +from typing import Optional + +from ..knowledge import gitea + +LOGGER = logging.getLogger("loogle_mcp.context.gitea_link") + +README_CANDIDATES = ("README.md", "readme.md", "Readme.md", "README.MD") +DOCS_DIR = "docs" +MAX_README_CHARS = 12_000 +MAX_DOC_FILES = 25 + + +def normalize_gitea_repo(repo: Optional[str]) -> Optional[str]: + if repo is None: + return None + cleaned = repo.strip() + if not cleaned: + return None + owner, name = gitea.parse_repo(cleaned) + return f"{owner}/{name}" + + +def verify_repo_access(username: str, repo: str) -> None: + owner, name = gitea.parse_repo(repo) + gitea._request("GET", f"/repos/{owner}/{name}", username=username) + + +def fetch_readme(username: str, repo: str) -> Optional[dict]: + for path in README_CANDIDATES: + try: + data = gitea.get_file(repo, path, username=username) + except FileNotFoundError: + continue + except Exception as exc: + LOGGER.warning("README %s/%s non leggibile: %s", repo, path, exc) + continue + if data.get("type") != "file": + continue + content = (data.get("content") or "").strip() + if not content: + continue + truncated = len(content) > MAX_README_CHARS + return { + "path": path, + "sha": data.get("sha"), + "html_url": data.get("html_url"), + "content": content[:MAX_README_CHARS], + "truncated": truncated, + } + return None + + +def fetch_docs_index(username: str, repo: str) -> list[dict]: + try: + data = gitea.get_file(repo, DOCS_DIR, username=username) + except FileNotFoundError: + return [] + except Exception as exc: + LOGGER.warning("Directory docs/ non leggibile per %s: %s", repo, exc) + return [] + + if data.get("type") != "dir": + return [] + + entries = [] + for item in data.get("entries") or []: + if item.get("type") != "file": + continue + path = item.get("path") or "" + if not path.lower().endswith((".md", ".txt", ".rst")): + continue + entries.append( + { + "path": path, + "size": item.get("size"), + } + ) + if len(entries) >= MAX_DOC_FILES: + break + return entries + + +def project_enrichment(username: str, gitea_repo: str) -> dict: + repo = normalize_gitea_repo(gitea_repo) + if not repo: + return {"linked": False} + + base = { + "linked": True, + "repo": repo, + "html_url": f"{gitea.public_base_url()}/{repo}", + } + + if not gitea.is_configured(username): + return { + **base, + "available": False, + "error": "Gitea non configurato per questo utente", + } + + try: + verify_repo_access(username, repo) + readme = fetch_readme(username, repo) + docs = fetch_docs_index(username, repo) + open_issues = None + try: + issues = gitea.list_issues(repo, state="open", limit=1, username=username) + open_issues = issues.get("count", 0) + except Exception: + pass + return { + **base, + "available": True, + "readme": readme, + "docs_files": docs, + "open_issues": open_issues, + } + except Exception as exc: + return { + **base, + "available": False, + "error": str(exc), + } + + +def readme_seed_block(repo: str, readme: dict) -> str: + path = readme.get("path") or "README.md" + content = readme.get("content") or "" + truncated_note = "\n\n*(README troncato — usa get_file per il testo completo)*" if readme.get("truncated") else "" + return ( + f"\n\n" + f"## Sorgente Gitea: `{repo}`\n\n" + f"Contenuto iniziale da `{path}`.\n\n" + f"{content.rstrip()}{truncated_note}\n" + ) + + +def seed_context_from_readme(username: str, repo: str, context_md: str) -> tuple[str, bool]: + """Importa README nel context se non già presente un seed Gitea.""" + if "\n\n" + text + return { + "uri": uri, + "mimeType": "text/markdown", + "text": text, + } diff --git a/services/loogle-mcp/app/db.py b/services/loogle-mcp/app/db.py new file mode 100644 index 0000000..36a3eec --- /dev/null +++ b/services/loogle-mcp/app/db.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +"""SQLite schema for Loogle MCP Hub.""" + +import os +import sqlite3 +import threading + +DB_PATH = os.environ.get("MCP_DB", "/data/loogle_mcp.db") +_local = threading.local() + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + must_change_password INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS oauth_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + scope TEXT NOT NULL, + code_challenge TEXT, + code_challenge_method TEXT, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id TEXT NOT NULL, + scope TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS revoked_jtis ( + jti TEXT PRIMARY KEY, + revoked_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + tool_name TEXT NOT NULL, + resource_id TEXT, + detail TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) +); + +CREATE TABLE IF NOT EXISTS indexed_documents ( + doc_id INTEGER PRIMARY KEY, + title TEXT, + owner TEXT, + visibility TEXT NOT NULL DEFAULT 'family', + indexed_at TEXT NOT NULL DEFAULT (datetime('now')), + chunk_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS indexed_gitea_files ( + repo TEXT NOT NULL, + path TEXT NOT NULL, + sha TEXT, + owner TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'family', + chunk_count INTEGER NOT NULL DEFAULT 0, + indexed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (repo, path) +); + +CREATE INDEX IF NOT EXISTS idx_gitea_indexed_repo ON indexed_gitea_files(repo); +CREATE INDEX IF NOT EXISTS idx_gitea_indexed_at ON indexed_gitea_files(indexed_at DESC); + +CREATE TABLE IF NOT EXISTS indexed_apps_records ( + source TEXT NOT NULL, + record_id TEXT NOT NULL, + title TEXT, + owner TEXT NOT NULL DEFAULT 'family', + chunk_count INTEGER NOT NULL DEFAULT 0, + indexed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (source, record_id) +); + +CREATE INDEX IF NOT EXISTS idx_apps_indexed_source ON indexed_apps_records(source); +CREATE INDEX IF NOT EXISTS idx_apps_indexed_at ON indexed_apps_records(indexed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at); +""" + + +def get_conn() -> sqlite3.Connection: + conn = getattr(_local, "conn", None) + if conn is None: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + _local.conn = conn + return conn + + +def init_db() -> None: + get_conn().executescript(SCHEMA) diff --git a/services/loogle-mcp/app/integrations/__init__.py b/services/loogle-mcp/app/integrations/__init__.py new file mode 100644 index 0000000..aaafed1 --- /dev/null +++ b/services/loogle-mcp/app/integrations/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Client REST verso app homelab LOOGLE.""" diff --git a/services/loogle-mcp/app/integrations/casa.py b/services/loogle-mcp/app/integrations/casa.py new file mode 100644 index 0000000..1cb3c46 --- /dev/null +++ b/services/loogle-mcp/app/integrations/casa.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""Client Loogle Casa — dashboard, meteo, rete.""" + +import os +from typing import Any, Optional + +from .session_client import SessionApiClient + +MCP_USERS = ("daniele", "lucia", "davide", "luca") +# Daniele MCP → admin su Loogle Casa +MCP_TO_SERVICE_USER = { + "daniele": "admin", + "lucia": "lucia", + "davide": "davide", + "luca": "luca", +} + + +def _service_username(mcp_username: str) -> str: + return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower()) +PUBLIC_URL = os.environ.get("LOOGLE_CASA_URL", "https://casa.loogle.it").rstrip("/") +API_URL = os.environ.get("LOOGLE_CASA_API_URL", PUBLIC_URL).rstrip("/") + +_client: Optional[SessionApiClient] = None + + +def _client_instance() -> SessionApiClient: + global _client + if _client is None: + verify = os.environ.get("LOOGLE_CASA_VERIFY_SSL", "true").strip().lower() not in ( + "0", "false", "no", "off", + ) + client = SessionApiClient( + service="LOOGLE_CASA", + base_url=API_URL, + verify_ssl=verify, + ) + client.map_username = _service_username # type: ignore[attr-defined] + _client = client + return _client + + +def is_configured(username: Optional[str] = None) -> bool: + user = (username or "daniele").lower() + if user in MCP_USERS: + key = f"LOOGLE_CASA_PASSWORD_{user.upper()}" + if os.environ.get(key, "").strip(): + return True + if os.environ.get("LOOGLE_CASA_PASSWORD", "").strip(): + return True + return user in MCP_USERS + + +def get_dashboard(username: str) -> dict: + return _client_instance().get("/api/dashboard", username=username) + + +def get_weather_home(username: str) -> dict: + return _client_instance().get("/api/weather/home", username=username) + + +def get_network_overview(username: str) -> dict: + return _client_instance().get("/api/network/overview", username=username) + + +def get_network_failover_status(username: str) -> dict: + return _client_instance().get("/api/network/failover/status", username=username) + + +def get_network_mcp_status(username: str) -> dict: + return _client_instance().get("/api/network/mcp", username=username) + + +def get_alerts_cards(username: str) -> Any: + return _client_instance().get("/api/alerts/cards", username=username) diff --git a/services/loogle-mcp/app/integrations/homeassistant.py b/services/loogle-mcp/app/integrations/homeassistant.py new file mode 100644 index 0000000..6244fc2 --- /dev/null +++ b/services/loogle-mcp/app/integrations/homeassistant.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""Client Home Assistant REST API (read-only).""" + +import os +from typing import Any, Optional +from urllib.parse import urljoin + +import httpx + +PUBLIC_URL = os.environ.get("HA_URL", "https://ha.loogle.it").rstrip("/") +API_URL = os.environ.get("HA_API_URL", PUBLIC_URL).rstrip("/") +HA_TOKEN = os.environ.get("HA_TOKEN", "").strip() + + +def is_configured() -> bool: + return bool(HA_TOKEN) + + +def _headers() -> dict: + if not HA_TOKEN: + raise RuntimeError( + "HA_TOKEN non configurato in .env — crea un long-lived token in Home Assistant" + ) + return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"} + + +def _request(method: str, path: str, *, params: Optional[dict] = None) -> Any: + verify = os.environ.get("HA_VERIFY_SSL", "true").strip().lower() not in ( + "0", "false", "no", "off", + ) + url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/")) + with httpx.Client(timeout=30.0, verify=verify) as client: + resp = client.request(method, url, headers=_headers(), params=params) + if resp.status_code >= 400: + raise RuntimeError(f"Home Assistant {path}: HTTP {resp.status_code} {resp.text[:200]}") + return resp.json() + + +def get_config() -> dict: + return _request("GET", "/api/config") + + +def get_entity(entity_id: str) -> dict: + return _request("GET", f"/api/states/{entity_id}") + + +def list_entities(domain: Optional[str] = None, limit: int = 100) -> list: + states = _request("GET", "/api/states") + if domain: + prefix = domain if domain.endswith(".") else f"{domain}." + states = [s for s in states if s.get("entity_id", "").startswith(prefix)] + return states[:limit] + + +def search_entities(query: str, limit: int = 30) -> list: + q = query.lower() + matches = [] + for state in _request("GET", "/api/states"): + eid = state.get("entity_id", "") + name = (state.get("attributes") or {}).get("friendly_name", "") + blob = f"{eid} {name}".lower() + if q in blob: + matches.append({ + "entity_id": eid, + "state": state.get("state"), + "friendly_name": name, + "last_changed": state.get("last_changed"), + }) + if len(matches) >= limit: + break + return matches diff --git a/services/loogle-mcp/app/integrations/irrigazione.py b/services/loogle-mcp/app/integrations/irrigazione.py new file mode 100644 index 0000000..07ed279 --- /dev/null +++ b/services/loogle-mcp/app/integrations/irrigazione.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""Client Irrigazione Smart — irri.loogle.it.""" + +import os +from typing import Any, Optional + +from .session_client import SessionApiClient + +MCP_USERS = ("daniele", "lucia", "davide", "luca") +MCP_TO_SERVICE_USER = { + "daniele": "admin", + "lucia": "lucia", + "davide": "dado", + "luca": "luca", +} + +PUBLIC_URL = os.environ.get("IRRIGAZIONE_URL", "https://irri.loogle.it").rstrip("/") +API_URL = os.environ.get("IRRIGAZIONE_API_URL", PUBLIC_URL).rstrip("/") + +_client: Optional[SessionApiClient] = None + + +def _service_username(mcp_username: str) -> str: + return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower()) + + +def _client_instance() -> SessionApiClient: + global _client + if _client is None: + verify = os.environ.get("IRRIGAZIONE_VERIFY_SSL", "true").strip().lower() not in ( + "0", "false", "no", "off", + ) + client = SessionApiClient( + service="IRRIGAZIONE", + base_url=API_URL, + verify_ssl=verify, + ) + client.map_username = _service_username # type: ignore[attr-defined] + _client = client + return _client + + +def is_configured(username: Optional[str] = None) -> bool: + user = (username or "daniele").lower() + if os.environ.get(f"IRRIGAZIONE_PASSWORD_{user.upper()}", "").strip(): + return True + if os.environ.get("IRRIGAZIONE_PASSWORD", "").strip(): + return True + return user in MCP_USERS + + +def get_status(username: str) -> dict: + return _client_instance().get("/api/status", username=username) + + +def get_zones(username: str) -> Any: + return _client_instance().get("/api/zones", username=username) + + +def get_history(username: str, limit: int = 30) -> Any: + data = _client_instance().get("/api/history", username=username) + if isinstance(data, list): + return data[:limit] + if isinstance(data, dict) and "items" in data: + items = data["items"] + return items[:limit] if isinstance(items, list) else data + return data + + +def get_events(username: str, limit: int = 50) -> Any: + data = _client_instance().get("/api/events", username=username) + if isinstance(data, list): + return data[:limit] + return data + + +def get_lavori_summary(username: str) -> Any: + return _client_instance().get("/api/lavori/summary", username=username) diff --git a/services/loogle-mcp/app/integrations/session_client.py b/services/loogle-mcp/app/integrations/session_client.py new file mode 100644 index 0000000..075aca9 --- /dev/null +++ b/services/loogle-mcp/app/integrations/session_client.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""Client HTTP con sessione cookie (Loogle Casa, Irrigazione).""" + +import logging +import threading +import time +from typing import Any, Optional +from urllib.parse import urljoin + +import httpx + +LOGGER = logging.getLogger("loogle_mcp.session_client") + +_sessions: dict[str, tuple[str, float]] = {} +_sessions_lock = threading.Lock() +SESSION_TTL = 3600 * 12 + + +class SessionApiClient: + """Login cookie-based con cache per utente MCP.""" + + def __init__( + self, + *, + service: str, + base_url: str, + login_path: str = "/api/login", + verify_ssl: bool = True, + ) -> None: + self.service = service + self.base_url = base_url.rstrip("/") + self.login_path = login_path + self.verify_ssl = verify_ssl + + def _password_for_user(self, username: str) -> Optional[str]: + import os + user = username.lower() + env_key = f"{self.service}_PASSWORD_{user.upper()}" + pwd = os.environ.get(env_key, "").strip() + if pwd: + return pwd + fallback = os.environ.get(f"{self.service}_PASSWORD", "").strip() + if fallback: + return fallback + return user + + def _cache_key(self, username: str) -> str: + return f"{self.service}:{username.lower()}" + + def _get_cached_cookie(self, username: str) -> Optional[str]: + key = self._cache_key(username) + with _sessions_lock: + row = _sessions.get(key) + if not row: + return None + cookie, expires = row + if time.time() > expires: + _sessions.pop(key, None) + return None + return cookie + + def _store_cookie(self, username: str, cookie: str) -> None: + key = self._cache_key(username) + with _sessions_lock: + _sessions[key] = (cookie, time.time() + SESSION_TTL) + + def login(self, username: str) -> str: + cached = self._get_cached_cookie(username) + if cached: + return cached + service_user = username + if hasattr(self, "map_username"): + service_user = self.map_username(username) # type: ignore[attr-defined] + password = self._password_for_user(username) + if not password: + raise RuntimeError( + f"Password {self.service} non configurata per {username}. " + f"Imposta {self.service}_PASSWORD_{username.upper()} in .env" + ) + url = urljoin(self.base_url + "/", self.login_path.lstrip("/")) + with httpx.Client(timeout=30.0, verify=self.verify_ssl) as client: + resp = client.post( + url, json={"username": service_user, "password": password}, + ) + if resp.status_code != 200: + raise RuntimeError( + f"Login {self.service} fallito per {username}: HTTP {resp.status_code}" + ) + cookie = resp.cookies.get("session") + if not cookie: + raise RuntimeError(f"Login {self.service}: cookie session mancante") + self._store_cookie(username, cookie) + return cookie + + def request( + self, + method: str, + path: str, + *, + username: str, + params: Optional[dict] = None, + json_body: Optional[dict] = None, + ) -> Any: + cookie = self.login(username) + url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) + headers = {"Cookie": f"session={cookie}"} + with httpx.Client(timeout=60.0, verify=self.verify_ssl) as client: + resp = client.request(method, url, headers=headers, params=params, json=json_body) + if resp.status_code == 401: + with _sessions_lock: + _sessions.pop(self._cache_key(username), None) + cookie = self.login(username) + headers = {"Cookie": f"session={cookie}"} + resp = client.request(method, url, headers=headers, params=params, json=json_body) + if resp.status_code >= 400: + raise RuntimeError(f"{self.service} {method} {path}: HTTP {resp.status_code} {resp.text[:200]}") + if resp.headers.get("content-type", "").startswith("application/json"): + return resp.json() + return resp.text + + def get(self, path: str, *, username: str, params: Optional[dict] = None) -> Any: + return self.request("GET", path, username=username, params=params) diff --git a/services/loogle-mcp/app/integrations/turni.py b/services/loogle-mcp/app/integrations/turni.py new file mode 100644 index 0000000..478472c --- /dev/null +++ b/services/loogle-mcp/app/integrations/turni.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +"""Client Turni-Live — turni.loogle.it (JWT Bearer).""" + +import logging +import os +import threading +import time +from typing import Any, Optional +from urllib.parse import urljoin + +import httpx + +LOGGER = logging.getLogger("loogle_mcp.turni") + +MCP_USERS = ("daniele", "lucia", "davide", "luca") +MCP_TO_SERVICE_USER = { + "daniele": "daniely", + "lucia": "lucia", + "davide": "davide", + "luca": "luca", +} + +PUBLIC_URL = os.environ.get("TURNI_URL", "https://turni.loogle.it").rstrip("/") +API_URL = os.environ.get("TURNI_API_URL", PUBLIC_URL).rstrip("/") + +_jwt_cache: dict[str, tuple[str, float]] = {} +_jwt_lock = threading.Lock() +JWT_TTL = 3600 * 6 + + +def _service_username(mcp_username: str) -> str: + return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower()) + + +def _password_for_user(mcp_username: str) -> Optional[str]: + user = mcp_username.lower() + pwd = os.environ.get(f"TURNI_PASSWORD_{user.upper()}", "").strip() + if pwd: + return pwd + return os.environ.get("TURNI_PASSWORD", "").strip() or None + + +def _jwt_for_user(mcp_username: str) -> Optional[str]: + user = mcp_username.lower() + direct = os.environ.get(f"TURNI_JWT_{user.upper()}", "").strip() + if direct: + return direct + return os.environ.get("TURNI_JWT", "").strip() or None + + +def is_configured(username: Optional[str] = None) -> bool: + user = (username or "daniele").lower() + if _jwt_for_user(user): + return True + if _password_for_user(user): + return True + return False + + +def _store_jwt(mcp_username: str, token: str) -> None: + with _jwt_lock: + _jwt_cache[mcp_username.lower()] = (token, time.time() + JWT_TTL) + + +def _cached_jwt(mcp_username: str) -> Optional[str]: + with _jwt_lock: + row = _jwt_cache.get(mcp_username.lower()) + if not row: + return None + token, expires = row + if time.time() > expires: + _jwt_cache.pop(mcp_username.lower(), None) + return None + return token + + +def login(mcp_username: str) -> str: + cached = _cached_jwt(mcp_username) + if cached: + return cached + preset = _jwt_for_user(mcp_username) + if preset: + _store_jwt(mcp_username, preset) + return preset + password = _password_for_user(mcp_username) + if not password: + raise RuntimeError( + f"Turni non configurato per {mcp_username}. " + f"Imposta TURNI_PASSWORD_{mcp_username.upper()} o TURNI_JWT_{mcp_username.upper()}" + ) + service_user = _service_username(mcp_username) + verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in ( + "0", "false", "no", "off", + ) + url = urljoin(API_URL + "/", "api/auth/login") + with httpx.Client(timeout=30.0, verify=verify) as client: + resp = client.post(url, json={"username": service_user, "password": password}) + if resp.status_code != 200: + raise RuntimeError(f"Login Turni fallito: HTTP {resp.status_code}") + data = resp.json() + token = data.get("token") + if not token: + raise RuntimeError("Login Turni: token JWT mancante") + _store_jwt(mcp_username, token) + return token + + +def _request( + method: str, + path: str, + *, + mcp_username: str, + params: Optional[dict] = None, +) -> Any: + token = login(mcp_username) + verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in ( + "0", "false", "no", "off", + ) + url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/")) + headers = {"Authorization": f"Bearer {token}"} + with httpx.Client(timeout=60.0, verify=verify) as client: + resp = client.request(method, url, headers=headers, params=params) + if resp.status_code == 401: + with _jwt_lock: + _jwt_cache.pop(mcp_username.lower(), None) + headers["Authorization"] = f"Bearer {login(mcp_username)}" + resp = client.request(method, url, headers=headers, params=params) + if resp.status_code >= 400: + raise RuntimeError(f"Turni {path}: HTTP {resp.status_code} {resp.text[:200]}") + return resp.json() + + +def get_status() -> dict: + verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in ( + "0", "false", "no", "off", + ) + url = urljoin(API_URL + "/", "api/status") + with httpx.Client(timeout=30.0, verify=verify) as client: + resp = client.get(url) + resp.raise_for_status() + return resp.json() + + +def list_doctors(mcp_username: str) -> Any: + return _request("GET", "/api/doctors", mcp_username=mcp_username) + + +def get_shift_assignments( + mcp_username: str, + *, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + limit: int = 100, +) -> Any: + params: dict = {} + if from_date: + params["from"] = from_date + if to_date: + params["to"] = to_date + data = _request("GET", "/api/shift-assignments", mcp_username=mcp_username, params=params or None) + if isinstance(data, list): + return data[:limit] + if isinstance(data, dict): + items = data.get("assignments") or data.get("items") or data.get("results") + if isinstance(items, list): + return items[:limit] + return data + + +def get_my_shifts( + mcp_username: str, + *, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + limit: int = 50, +) -> dict: + """Turni dell'utente MCP: filtra per doctorId collegato o per nome medico.""" + user_info = _request("GET", "/api/users/me", mcp_username=mcp_username) + doctor_id = user_info.get("doctorId") + assignments = get_shift_assignments( + mcp_username, from_date=from_date, to_date=to_date, limit=500, + ) + if not isinstance(assignments, list): + return {"user": user_info, "assignments": assignments} + if doctor_id: + mine = [a for a in assignments if a.get("doctorId") == doctor_id or a.get("doctor_id") == doctor_id] + else: + service_user = _service_username(mcp_username) + doctors = list_doctors(mcp_username) + doc_ids = set() + if isinstance(doctors, list): + for doc in doctors: + name = (doc.get("name") or doc.get("fullName") or "").lower() + if service_user.lower() in name or mcp_username.lower() in name: + doc_ids.add(doc.get("id") or doc.get("doctorId")) + mine = [ + a for a in assignments + if (a.get("doctorId") or a.get("doctor_id")) in doc_ids + ] if doc_ids else assignments[:limit] + return { + "user": { + "username": user_info.get("username"), + "role": user_info.get("role"), + "doctorId": doctor_id, + }, + "assignments": mine[:limit], + "count": len(mine), + } diff --git a/services/loogle-mcp/app/jwt_utils.py b/services/loogle-mcp/app/jwt_utils.py new file mode 100644 index 0000000..288b715 --- /dev/null +++ b/services/loogle-mcp/app/jwt_utils.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""JWT utilities for OAuth access tokens.""" + +import datetime +import os +import secrets +import uuid +from typing import Optional + +import jwt + +from .db import get_conn + +JWT_ALG = "HS256" +ACCESS_TOKEN_HOURS = 1 +REFRESH_TOKEN_DAYS = 30 + + +def jwt_secret() -> str: + secret = os.environ.get("MCP_JWT_SECRET", "").strip() + if not secret: + secret = secrets.token_urlsafe(48) + os.environ["MCP_JWT_SECRET"] = secret + return secret + + +def base_url() -> str: + return os.environ.get("MCP_BASE_URL", "https://mcp.loogle.it").rstrip("/") + + +def scopes_for_user(user: dict, requested: str) -> str: + parts = [s for s in requested.split() if s] + allowed = { + "context:read", + "context:write", + "knowledge:read", + "knowledge:write", + "gitea:read", + "gitea:write", + "home:read", + "irrigation:read", + "turni:read", + } + if user.get("is_admin"): + allowed.add("admin") + filtered = [s for s in parts if s in allowed] + if not filtered: + filtered = [ + "context:read", + "context:write", + "knowledge:read", + "knowledge:write", + "gitea:read", + "gitea:write", + "home:read", + "irrigation:read", + "turni:read", + ] + return " ".join(filtered) + + +def create_access_token(user: dict, scope: str, client_id: str) -> tuple[str, str]: + jti = str(uuid.uuid4()) + now = datetime.datetime.utcnow() + payload = { + "iss": base_url(), + "sub": user["username"], + "uid": user["id"], + "scope": scope, + "client_id": client_id, + "jti": jti, + "iat": now, + "exp": now + datetime.timedelta(hours=ACCESS_TOKEN_HOURS), + } + token = jwt.encode(payload, jwt_secret(), algorithm=JWT_ALG) + return token, jti + + +def create_refresh_token(user_id: int, scope: str, client_id: str) -> str: + token = secrets.token_urlsafe(48) + expires = ( + datetime.datetime.utcnow() + datetime.timedelta(days=REFRESH_TOKEN_DAYS) + ).strftime("%Y-%m-%d %H:%M:%S") + get_conn().execute( + "INSERT INTO refresh_tokens(token,user_id,client_id,scope,expires_at) VALUES (?,?,?,?,?)", + (token, user_id, client_id, scope, expires), + ) + get_conn().commit() + return token + + +def decode_access_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, jwt_secret(), algorithms=[JWT_ALG], issuer=base_url()) + except jwt.PyJWTError: + return None + jti = payload.get("jti") + if not jti: + return None + row = get_conn().execute( + "SELECT 1 FROM revoked_jtis WHERE jti=? AND expires_at > datetime('now')", + (jti,), + ).fetchone() + if row: + return None + return payload + + +def revoke_jti(jti: str, expires_at: datetime.datetime) -> None: + get_conn().execute( + "INSERT OR IGNORE INTO revoked_jtis(jti,expires_at) VALUES (?,?)", + (jti, expires_at.strftime("%Y-%m-%d %H:%M:%S")), + ) + get_conn().commit() + + +def revoke_refresh_token(token: str) -> None: + get_conn().execute("UPDATE refresh_tokens SET revoked=1 WHERE token=?", (token,)) + get_conn().commit() + + +def consume_refresh_token(token: str) -> Optional[dict]: + row = get_conn().execute( + "SELECT * FROM refresh_tokens WHERE token=? AND revoked=0 AND expires_at > datetime('now')", + (token,), + ).fetchone() + if not row: + return None + return dict(row) + + +def has_scope(claims: dict, scope: str) -> bool: + scopes = set((claims.get("scope") or "").split()) + if "admin" in scopes: + return True + return scope in scopes diff --git a/services/loogle-mcp/app/knowledge/__init__.py b/services/loogle-mcp/app/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/loogle-mcp/app/knowledge/apps_indexer.py b/services/loogle-mcp/app/knowledge/apps_indexer.py new file mode 100644 index 0000000..32b9278 --- /dev/null +++ b/services/loogle-mcp/app/knowledge/apps_indexer.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +"""RAG su export Irrigazione + Turni (P7).""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone +from typing import Any, Optional + +from ..db import get_conn +from ..integrations import irrigazione, turni +from . import embeddings, qdrant_store +from .text_chunk import chunk_text as _chunk_text + +LOGGER = logging.getLogger("loogle_mcp.apps_indexer") + +INDEX_USER = os.environ.get("APPS_INDEX_USER", "daniele").lower() + + +def _int_env(name: str, default: int) -> int: + try: + return int(os.environ.get(name, str(default))) + except ValueError: + return default + + +def _enabled() -> bool: + flag = os.environ.get("APPS_INDEX_ENABLED", "yes").strip().lower() + return flag not in ("0", "false", "no", "off") + + +def _doc_id(source: str, record_id: str) -> int: + key = f"{source}:{record_id}" + return abs(hash(key)) % (2**31 - 1) + + +def _index_text(source: str, record_id: str, title: str, text: str) -> dict: + chunks = _chunk_text(_truncate(text)) + if len(chunks) > 8: + chunks = chunks[:8] + if not chunks: + return {"source": source, "record_id": record_id, "chunks": 0, "skipped": True} + vectors = embeddings.embed_texts(chunks) + collection = qdrant_store.APPS_SHARED_COLLECTION + doc_id = _doc_id(source, record_id) + qdrant_store.delete_by_doc(collection, doc_id) + ids = [] + payloads = [] + for i, chunk in enumerate(chunks): + point_id = f"app-{source}-{record_id}-chunk-{i}" + ids.append(point_id) + payloads.append( + { + "doc_id": doc_id, + "source": source, + "record_id": record_id, + "chunk_index": i, + "title": title, + "text": chunk, + "owner": "family", + "visibility": "family", + } + ) + qdrant_store.upsert_chunks(collection, ids, vectors, payloads) + get_conn().execute( + "INSERT INTO indexed_apps_records(source,record_id,title,owner,chunk_count,indexed_at)" + " VALUES (?,?,?,?,?,datetime('now'))" + " ON CONFLICT(source,record_id) DO UPDATE SET" + " title=excluded.title, chunk_count=excluded.chunk_count, indexed_at=datetime('now')", + (source, record_id, title, "family", len(chunks)), + ) + get_conn().commit() + return {"source": source, "record_id": record_id, "chunks": len(chunks), "collection": collection} + + +def _format_irrigation_history_item(item: dict, idx: int) -> str: + parts = [f"Irrigazione storico #{idx}"] + for key in ("started_at", "ended_at", "zone", "zone_name", "duration_min", "volume_l", "mode", "note"): + if item.get(key) is not None: + parts.append(f"{key}: {item[key]}") + return "\n".join(parts) + + +def _format_irrigation_event(item: dict, idx: int) -> str: + parts = [f"Irrigazione evento #{idx}"] + for key in ("ts", "time", "type", "level", "message", "zone", "detail"): + if item.get(key) is not None: + parts.append(f"{key}: {item[key]}") + return "\n".join(parts) + + +def _format_turni_assignment(item: dict, idx: int) -> str: + parts = [f"Turno #{idx}"] + for key in ( + "date", "startDate", "endDate", "doctorId", "doctorName", "doctor_name", + "slotId", "slotName", "slot_name", "uoc", "uocName", "shiftType", "notes", + ): + if item.get(key) is not None: + parts.append(f"{key}: {item[key]}") + return "\n".join(parts) + + +def _truncate(text: str, limit: int = 6000) -> str: + if len(text) <= limit: + return text + return text[: limit - 20] + "\n… [truncated]" + + +def _summarize_irrigation_status(status: dict) -> str: + lines = ["Irrigazione — snapshot stato"] + for key in ("plan_mode", "program", "simulation", "hibernation", "draining"): + if key in status: + lines.append(f"{key}: {status[key]}") + ha = status.get("ha") or {} + lines.append(f"ha_connected: {ha.get('connected')}") + zones = status.get("zones") or [] + lines.append(f"zone_count: {len(zones)}") + for z in zones[:12]: + if isinstance(z, dict): + lines.append( + f" - {z.get('name', z.get('id'))}: state={z.get('ha_state')} excluded={z.get('excluded')}" + ) + analysis = status.get("analysis") + if isinstance(analysis, dict): + for k, v in list(analysis.items())[:8]: + lines.append(f"analysis.{k}: {v}") + elif analysis: + lines.append(f"analysis: {analysis}") + return "\n".join(lines) + + +def _summarize_zones(zones: Any) -> str: + items = zones if isinstance(zones, list) else _normalize_list(zones) + lines = [f"Irrigazione — zone ({len(items)})"] + for z in items[:20]: + if not isinstance(z, dict): + continue + lines.append( + f"- {z.get('name', z.get('id'))}: ha={z.get('ha_state')} " + f"rate_mmh={z.get('rate_mmh')} flow={z.get('zone_flow_lph')}" + ) + return "\n".join(lines) + + +def _normalize_list(data: Any) -> list: + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("items", "results", "history", "events", "assignments", "records"): + val = data.get(key) + if isinstance(val, list): + return val + return [] + + +def index_irrigazione(*, username: Optional[str] = None, history_limit: Optional[int] = None, events_limit: Optional[int] = None) -> dict: + user = username or INDEX_USER + history_limit = history_limit if history_limit is not None else _int_env("APPS_INDEX_HISTORY_LIMIT", 25) + events_limit = events_limit if events_limit is not None else _int_env("APPS_INDEX_EVENTS_LIMIT", 40) + if not irrigazione.is_configured(user): + return {"source": "irrigazione", "skipped": True, "reason": "not configured"} + indexed = 0 + errors = 0 + try: + status = irrigazione.get_status(user) + status_text = _summarize_irrigation_status(status) + _index_text("irrigazione", "status-snapshot", "Irrigazione — stato attuale", status_text) + indexed += 1 + except Exception as exc: + LOGGER.warning("Irrigazione status index failed: %s", exc) + errors += 1 + try: + zones = irrigazione.get_zones(user) + zones_text = _summarize_zones(zones) + _index_text("irrigazione", "zones-snapshot", "Irrigazione — zone", zones_text) + indexed += 1 + except Exception as exc: + LOGGER.warning("Irrigazione zones index failed: %s", exc) + errors += 1 + try: + history = irrigazione.get_history(user, limit=history_limit) + for i, item in enumerate(_normalize_list(history)): + if not isinstance(item, dict): + continue + rid = str(item.get("id") or item.get("started_at") or i) + text = _format_irrigation_history_item(item, i) + _index_text("irrigazione", f"history-{rid}", f"Irrigazione storico {rid}", text) + indexed += 1 + except Exception as exc: + LOGGER.warning("Irrigazione history index failed: %s", exc) + errors += 1 + try: + events = irrigazione.get_events(user, limit=events_limit) + for i, item in enumerate(_normalize_list(events)): + if not isinstance(item, dict): + continue + rid = str(item.get("id") or item.get("ts") or item.get("time") or i) + text = _format_irrigation_event(item, i) + _index_text("irrigazione", f"event-{rid}", f"Irrigazione evento {rid}", text) + indexed += 1 + except Exception as exc: + LOGGER.warning("Irrigazione events index failed: %s", exc) + errors += 1 + try: + lavori = irrigazione.get_lavori_summary(user) + lavori_text = _truncate(json.dumps(lavori, ensure_ascii=False, indent=2), 4000) + _index_text("irrigazione", "lavori-summary", "Irrigazione — lavori manutenzione", lavori_text) + indexed += 1 + except Exception as exc: + LOGGER.warning("Irrigazione lavori index failed: %s", exc) + errors += 1 + return {"source": "irrigazione", "indexed": indexed, "errors": errors} + + +def index_turni(*, username: Optional[str] = None, assignments_limit: Optional[int] = None) -> dict: + user = username or INDEX_USER + assignments_limit = assignments_limit if assignments_limit is not None else _int_env("APPS_INDEX_ASSIGNMENTS_LIMIT", 80) + if not turni.is_configured(user): + return {"source": "turni", "skipped": True, "reason": "not configured"} + indexed = 0 + errors = 0 + try: + status = turni.get_status() + status_text = _truncate(json.dumps(status, ensure_ascii=False, indent=2), 2000) + _index_text("turni", "status-snapshot", "Turni — stato servizio", status_text) + indexed += 1 + except Exception as exc: + LOGGER.warning("Turni status index failed: %s", exc) + errors += 1 + try: + doctors = turni.list_doctors(user) + lines = ["Turni — medici"] + for d in (_normalize_list(doctors) if not isinstance(doctors, list) else doctors)[:40]: + if isinstance(d, dict): + lines.append(f"- {d.get('name', d.get('fullName'))} id={d.get('id')}") + _index_text("turni", "doctors-list", "Turni — elenco medici", "\n".join(lines)) + indexed += 1 + except Exception as exc: + LOGGER.warning("Turni doctors index failed: %s", exc) + errors += 1 + try: + assignments = turni.get_shift_assignments(user, limit=assignments_limit) + for i, item in enumerate(_normalize_list(assignments)): + if not isinstance(item, dict): + continue + rid = str(item.get("id") or item.get("date") or i) + text = _format_turni_assignment(item, i) + _index_text("turni", f"assignment-{rid}", f"Turno {rid}", text) + indexed += 1 + except Exception as exc: + LOGGER.warning("Turni assignments index failed: %s", exc) + errors += 1 + return {"source": "turni", "indexed": indexed, "errors": errors} + + +def index_all(*, username: Optional[str] = None) -> dict: + if not _enabled(): + return {"skipped": True, "reason": "APPS_INDEX_ENABLED=no"} + user = username or INDEX_USER + return { + "irrigazione": index_irrigazione(username=user), + "turni": index_turni(username=user), + "at": datetime.now(timezone.utc).isoformat(), + } + + +def search_apps_knowledge(query: str, limit: int = 8, source: Optional[str] = None) -> list: + vectors = embeddings.embed_texts([query]) + flt = {"source": source} if source else None + hits = qdrant_store.search( + [qdrant_store.APPS_SHARED_COLLECTION], + vectors[0], + limit=limit, + visibility_filter=flt, + ) + for hit in hits: + hit.setdefault("source_type", hit.get("source", "apps")) + return hits + + +def list_indexed_records(source: Optional[str] = None, limit: int = 40) -> list: + conn = get_conn() + if source: + rows = conn.execute( + "SELECT * FROM indexed_apps_records WHERE source=? ORDER BY indexed_at DESC LIMIT ?", + (source, limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM indexed_apps_records ORDER BY indexed_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] diff --git a/services/loogle-mcp/app/knowledge/embeddings.py b/services/loogle-mcp/app/knowledge/embeddings.py new file mode 100644 index 0000000..84b6389 --- /dev/null +++ b/services/loogle-mcp/app/knowledge/embeddings.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""Embedding providers — con thermal gate e keep_alive adattivo.""" + +import logging +import os +import time +from typing import Optional + +import httpx + +from . import thermal + +LOGGER = logging.getLogger("loogle_mcp.embeddings") + + +def embed_texts(texts: list[str]) -> list[list[float]]: + if not texts: + return [] + ollama_url = os.environ.get("OLLAMA_URL", "").strip() + if ollama_url: + try: + return _embed_ollama(texts, ollama_url) + except Exception as exc: + LOGGER.warning("Ollama embedding failed: %s", exc) + openai_key = os.environ.get("OPENAI_API_KEY", "").strip() + if openai_key: + return _embed_openai(texts, openai_key) + raise RuntimeError("Nessun provider embedding configurato (OLLAMA_URL o OPENAI_API_KEY)") + + +def _embed_ollama(texts: list[str], base_url: str) -> list[list[float]]: + model = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text") + vectors = [] + timeout = httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0) + with httpx.Client(timeout=timeout) as client: + for i, text in enumerate(texts): + status = thermal.wait_for_headroom(context=f"embed:{i+1}/{len(texts)}") + keep_alive = thermal.suggested_keep_alive(status) + delay = thermal.suggested_delay_s(status) + + payload = {"model": model, "prompt": text, "keep_alive": keep_alive} + # options.num_thread limita i thread CPU lato Ollama (se supportato) + num_thread = os.environ.get("OLLAMA_NUM_THREAD", "").strip() + if num_thread: + try: + payload["options"] = {"num_thread": int(num_thread)} + except ValueError: + pass + + resp = client.post(f"{base_url.rstrip('/')}/api/embeddings", json=payload) + if resp.status_code >= 400: + LOGGER.warning( + "Ollama embeddings HTTP %s: %s — payload keys=%s", + resp.status_code, + resp.text[:300], + list(payload.keys()), + ) + resp.raise_for_status() + vectors.append(resp.json()["embedding"]) + + if delay > 0 and i + 1 < len(texts): + time.sleep(delay) + + # Unload solo se esplicitamente richiesto (zona HARD) — evita spike da reload + if keep_alive == 0: + try: + client.post( + f"{base_url.rstrip('/')}/api/generate", + json={"model": model, "keep_alive": 0}, + timeout=30.0, + ) + except Exception: + pass + return vectors + + +def _embed_openai(texts: list[str], api_key: str) -> list[list[float]]: + model = os.environ.get("OPENAI_EMBED_MODEL", "text-embedding-3-small") + with httpx.Client(timeout=120.0) as client: + resp = client.post( + "https://api.openai.com/v1/embeddings", + headers={"Authorization": f"Bearer {api_key}"}, + json={"model": model, "input": texts}, + ) + resp.raise_for_status() + data = resp.json()["data"] + return [item["embedding"] for item in sorted(data, key=lambda x: x["index"])] diff --git a/services/loogle-mcp/app/knowledge/gitea.py b/services/loogle-mcp/app/knowledge/gitea.py new file mode 100644 index 0000000..5815291 --- /dev/null +++ b/services/loogle-mcp/app/knowledge/gitea.py @@ -0,0 +1,495 @@ +# -*- coding: utf-8 -*- +"""Gitea REST API client — token per utente MCP.""" + +import base64 +import json +import logging +import os +import re +from typing import Any, Optional +from urllib.parse import quote + +import httpx + +LOGGER = logging.getLogger("loogle_mcp.gitea") + +GITEA_URL = os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/") +MCP_USERS = ("daniele", "lucia", "davide", "luca") +GITEA_API_TOKEN_SCOPES = "read:repository,write:repository,write:issue,write:user,read:user" +TEXT_EXTENSIONS = { + ".md", ".txt", ".py", ".sh", ".yml", ".yaml", ".json", ".toml", ".ini", + ".conf", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".sql", ".go", + ".rs", ".env", ".service", ".timer", ".xml", ".csv", +} +_tokens_cache: Optional[dict[str, str]] = None + + +def _load_tokens() -> dict[str, str]: + global _tokens_cache + if _tokens_cache is not None: + return _tokens_cache + + tokens: dict[str, str] = {} + json_map = os.environ.get("GITEA_API_TOKENS", "").strip() + if json_map: + try: + parsed = json.loads(json_map) + if isinstance(parsed, dict): + tokens.update({k.lower(): v for k, v in parsed.items() if v}) + except json.JSONDecodeError: + LOGGER.warning("GITEA_API_TOKENS non è JSON valido") + + fallback = os.environ.get("GITEA_API_TOKEN", "").strip() + for user in MCP_USERS: + env_key = f"GITEA_API_TOKEN_{user.upper()}" + token = os.environ.get(env_key, "").strip() + if token: + tokens[user] = token + elif user not in tokens and fallback: + tokens[user] = fallback + + if not tokens and fallback: + tokens["daniele"] = fallback + + _tokens_cache = tokens + return tokens + + +def list_configured_users() -> list[str]: + return list(_load_tokens().keys()) + + +def is_configured(username: Optional[str] = None) -> bool: + tokens = _load_tokens() + if not tokens: + return False + if username: + user = username.lower() + return user in tokens or "daniele" in tokens or bool(tokens) + return True + + +def _headers(username: Optional[str] = None) -> dict[str, str]: + tokens = _load_tokens() + if not tokens: + raise RuntimeError( + "Nessun token Gitea configurato. " + "Imposta GITEA_API_TOKEN o GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md" + ) + user = (username or "daniele").lower() + token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values())) + return {"Authorization": f"token {token}"} + + +def parse_repo(repo: str) -> tuple[str, str]: + cleaned = repo.strip().strip("/") + if cleaned.count("/") != 1: + raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)") + owner, name = cleaned.split("/", 1) + if not owner or not name: + raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)") + return owner, name + + +def api_base_url() -> str: + return os.environ.get("GITEA_API_URL", GITEA_URL).rstrip("/") + + +def public_base_url() -> str: + return os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/") + + +def _api_url(path: str) -> str: + return f"{api_base_url()}/api/v1{path}" + + +def _request( + method: str, + path: str, + *, + username: Optional[str] = None, + params: Optional[dict] = None, + json_body: Optional[dict] = None, +) -> Any: + with httpx.Client(timeout=60.0, verify=True) as client: + resp = client.request( + method, + _api_url(path), + headers=_headers(username), + params=params, + json=json_body, + ) + if resp.status_code == 404: + raise FileNotFoundError(resp.text or "Risorsa Gitea non trovata") + resp.raise_for_status() + if resp.content: + return resp.json() + return {} + + +def list_repos( + username: Optional[str] = None, + page: int = 1, + limit: int = 50, +) -> dict: + data = _request( + "GET", + "/user/repos", + username=username, + params={"page": page, "limit": limit, "sort": "updated"}, + ) + repos = [] + for repo in data if isinstance(data, list) else []: + full_name = repo.get("full_name") or "" + if not full_name and repo.get("owner"): + full_name = f"{repo['owner'].get('login', '')}/{repo.get('name', '')}" + repos.append( + { + "full_name": full_name, + "description": repo.get("description") or "", + "private": bool(repo.get("private")), + "html_url": repo.get("html_url") or f"{public_base_url()}/{full_name}", + "default_branch": repo.get("default_branch") or "main", + "updated_at": repo.get("updated_at"), + } + ) + return {"repos": repos, "page": page, "count": len(repos)} + + +def _decode_content(entry: dict) -> str: + encoding = (entry.get("encoding") or "").lower() + raw = entry.get("content") or "" + if encoding == "base64": + return base64.b64decode(raw).decode("utf-8", errors="replace") + return raw + + +def get_file( + repo: str, + path: str, + ref: Optional[str] = None, + username: Optional[str] = None, +) -> dict: + owner, name = parse_repo(repo) + file_path = path.lstrip("/") + params = {} + if ref: + params["ref"] = ref + encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/")) + data = _request( + "GET", + f"/repos/{owner}/{name}/contents/{encoded_path}", + username=username, + params=params or None, + ) + if isinstance(data, list): + entries = [ + { + "name": item.get("name"), + "path": item.get("path"), + "type": item.get("type"), + "size": item.get("size"), + } + for item in data + ] + return { + "repo": f"{owner}/{name}", + "path": file_path or "/", + "type": "dir", + "entries": entries, + } + content = _decode_content(data) + return { + "repo": f"{owner}/{name}", + "path": data.get("path") or file_path, + "type": data.get("type") or "file", + "size": data.get("size"), + "sha": data.get("sha"), + "html_url": data.get("html_url") or f"{public_base_url()}/{owner}/{name}/src/branch/{ref or 'main'}/{file_path}", + "content": content, + } + + +def search_code( + query: str, + repo: Optional[str] = None, + limit: int = 20, + username: Optional[str] = None, +) -> dict: + q = query.strip() + if not q: + raise ValueError("query obbligatoria") + params: dict[str, Any] = {"q": q, "limit": min(max(limit, 1), 50)} + if repo: + owner, name = parse_repo(repo) + params["repo"] = f"{owner}/{name}" + try: + data = _request("GET", "/search/code", username=username, params=params) + hits = [] + for item in data.get("data") or []: + repo_name = item.get("repository", {}).get("full_name") or item.get("repository", {}).get("name") + hits.append( + { + "repo": repo_name, + "path": item.get("path"), + "sha": item.get("sha"), + "html_url": item.get("url") or item.get("html_url"), + "language": item.get("language"), + "snippet": (item.get("content") or item.get("text") or "")[:500], + } + ) + return {"query": q, "repo": repo, "results": hits, "count": len(hits)} + except FileNotFoundError: + return _search_code_fallback(q, repo, limit, username) + except httpx.HTTPStatusError as exc: + if exc.response.status_code not in (404, 422): + raise + return _search_code_fallback(q, repo, limit, username) + + +def _search_code_fallback( + query: str, + repo: Optional[str], + limit: int, + username: Optional[str], +) -> dict: + """Fallback se /search/code non disponibile: tree + grep su file testo.""" + repos: list[str] = [] + if repo: + owner, name = parse_repo(repo) + repos.append(f"{owner}/{name}") + else: + listed = list_repos(username=username, limit=20) + repos = [r["full_name"] for r in listed["repos"] if r.get("full_name")] + + terms = [t.lower() for t in re.split(r"\s+", query) if t] + hits: list[dict] = [] + max_files = min(limit * 3, 40) + + for full_name in repos: + owner, name = parse_repo(full_name) + try: + tree = _request( + "GET", + f"/repos/{owner}/{name}/git/trees/HEAD", + username=username, + params={"recursive": "1"}, + ) + except Exception: + continue + scanned = 0 + for node in tree.get("tree") or []: + if node.get("type") != "blob": + continue + path = node.get("path") or "" + ext = os.path.splitext(path)[1].lower() + if ext and ext not in TEXT_EXTENSIONS: + continue + if any(term in path.lower() for term in terms): + pass + scanned += 1 + if scanned > max_files: + break + try: + file_data = get_file(full_name, path, username=username) + except Exception: + continue + content = (file_data.get("content") or "").lower() + if not any(term in content or term in path.lower() for term in terms): + continue + snippet = file_data.get("content") or "" + idx = snippet.lower().find(terms[0]) if terms else 0 + if idx < 0: + idx = 0 + hits.append( + { + "repo": full_name, + "path": path, + "sha": node.get("sha"), + "html_url": file_data.get("html_url"), + "snippet": snippet[max(0, idx - 80): idx + 420], + } + ) + if len(hits) >= limit: + break + if len(hits) >= limit: + break + + return {"query": query, "repo": repo, "results": hits[:limit], "count": len(hits[:limit]), "mode": "fallback"} + + +def list_issues( + repo: str, + state: str = "open", + page: int = 1, + limit: int = 20, + username: Optional[str] = None, +) -> dict: + owner, name = parse_repo(repo) + data = _request( + "GET", + f"/repos/{owner}/{name}/issues", + username=username, + params={"state": state, "page": page, "limit": limit, "type": "issues"}, + ) + issues = [] + for item in data if isinstance(data, list) else []: + issues.append( + { + "number": item.get("number"), + "title": item.get("title"), + "state": item.get("state"), + "user": (item.get("user") or {}).get("login"), + "html_url": item.get("html_url"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + "labels": [lbl.get("name") for lbl in (item.get("labels") or [])], + } + ) + return {"repo": f"{owner}/{name}", "state": state, "issues": issues, "count": len(issues)} + + +def get_issue( + repo: str, + number: int, + username: Optional[str] = None, +) -> dict: + owner, name = parse_repo(repo) + item = _request("GET", f"/repos/{owner}/{name}/issues/{number}", username=username) + return { + "repo": f"{owner}/{name}", + "number": item.get("number"), + "title": item.get("title"), + "state": item.get("state"), + "body": item.get("body") or "", + "user": (item.get("user") or {}).get("login"), + "html_url": item.get("html_url"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + "labels": [lbl.get("name") for lbl in (item.get("labels") or [])], + } + + +def create_issue( + repo: str, + title: str, + body: str = "", + labels: Optional[list[str]] = None, + username: Optional[str] = None, +) -> dict: + owner, name = parse_repo(repo) + payload: dict[str, Any] = {"title": title.strip(), "body": body or ""} + if labels: + payload["labels"] = labels + item = _request( + "POST", + f"/repos/{owner}/{name}/issues", + username=username, + json_body=payload, + ) + return { + "repo": f"{owner}/{name}", + "number": item.get("number"), + "title": item.get("title"), + "state": item.get("state"), + "html_url": item.get("html_url"), + } + + +def assert_repo_owner(username: str, repo: str, *, is_admin: bool = False) -> tuple[str, str]: + owner, name = parse_repo(repo) + if not is_admin and owner.lower() != username.lower(): + raise PermissionError( + f"Puoi scrivere solo su repository di cui sei owner (repo {owner}/{name}, utente {username})" + ) + return owner, name + + +def create_repo( + name: str, + username: Optional[str] = None, + *, + private: bool = True, + description: str = "", + auto_init: bool = True, +) -> dict: + repo_name = name.strip().lower() + if not repo_name or not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", repo_name): + raise ValueError("name repo non valido (usa lettere minuscole, numeri, -, _, .)") + payload: dict[str, Any] = { + "name": repo_name, + "private": private, + "auto_init": auto_init, + "description": description.strip(), + } + item = _request("POST", "/user/repos", username=username, json_body=payload) + full_name = item.get("full_name") or f"{username}/{repo_name}" + return { + "full_name": full_name, + "private": bool(item.get("private", private)), + "html_url": item.get("html_url") or f"{public_base_url()}/{full_name}", + "default_branch": item.get("default_branch") or "main", + "description": item.get("description") or description, + } + + +def create_or_update_file( + repo: str, + path: str, + content: str, + message: str, + *, + branch: Optional[str] = None, + username: Optional[str] = None, +) -> dict: + owner, repo_name = parse_repo(repo) + file_path = path.lstrip("/") + if not file_path: + raise ValueError("path obbligatorio") + if not message.strip(): + raise ValueError("message commit obbligatorio") + + encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/")) + params = {} + if branch: + params["ref"] = branch + + sha = None + action = "create" + try: + existing = get_file(repo, file_path, ref=branch, username=username) + if existing.get("type") == "file": + sha = existing.get("sha") + action = "update" + except FileNotFoundError: + pass + + body: dict[str, Any] = { + "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), + "message": message.strip(), + } + if sha: + body["sha"] = sha + if branch: + body["branch"] = branch + + method = "PUT" if sha else "POST" + item = _request( + method, + f"/repos/{owner}/{repo_name}/contents/{encoded_path}", + username=username, + params=params or None, + json_body=body, + ) + commit = item.get("commit") or {} + content_obj = item.get("content") or {} + return { + "repo": f"{owner}/{repo_name}", + "path": file_path, + "action": action, + "branch": branch or "default", + "sha": content_obj.get("sha"), + "commit_sha": commit.get("sha"), + "html_url": content_obj.get("html_url") + or f"{public_base_url()}/{owner}/{repo_name}/src/branch/{branch or 'main'}/{file_path}", + } diff --git a/services/loogle-mcp/app/knowledge/gitea_indexer.py b/services/loogle-mcp/app/knowledge/gitea_indexer.py new file mode 100644 index 0000000..4cacdbd --- /dev/null +++ b/services/loogle-mcp/app/knowledge/gitea_indexer.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- +"""Indicizzazione semantica (RAG) su file testo dei repository Gitea.""" + +from __future__ import annotations + +import logging +import os +import re +import zlib +from typing import Optional + +from ..db import get_conn +from . import embeddings, gitea, qdrant_store +from .text_chunk import CHUNK_OVERLAP, CHUNK_SIZE, chunk_text as _chunk_text + +LOGGER = logging.getLogger("loogle_mcp.gitea_indexer") + +SKIP_PATH_PARTS = ( + "node_modules/", + "vendor/", + ".git/", + "dist/", + "build/", + "__pycache__/", + ".venv/", + "venv/", + ".tox/", + "coverage/", +) +PRIORITY_PREFIXES = ("docs/", "ha/", "doc/", "README") + + +def _enabled() -> bool: + flag = os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower() + return flag not in ("0", "false", "no", "off") + + +def _max_files_per_repo() -> int: + return int(os.environ.get("GITEA_INDEX_MAX_FILES_PER_REPO", "150")) + + +def _max_file_bytes() -> int: + return int(os.environ.get("GITEA_INDEX_MAX_FILE_BYTES", "120000")) + + +def _repos_for_user(username: str) -> list[str]: + env_key = f"GITEA_INDEX_REPOS_{username.upper()}" + raw = os.environ.get(env_key, "").strip() + if raw: + repos: list[str] = [] + for item in raw.split(","): + item = item.strip() + if not item: + continue + owner, name = gitea.parse_repo(item) + repos.append(f"{owner}/{name}") + return repos + repos: list[str] = [] + page = 1 + while page <= 5: + data = gitea.list_repos(username=username, page=page, limit=50) + batch = [r["full_name"] for r in data.get("repos") or [] if r.get("full_name")] + if not batch: + break + repos.extend(batch) + if len(batch) < 50: + break + page += 1 + return repos + + +def _should_index_path(path: str) -> bool: + lowered = path.lower() + if any(part in lowered for part in SKIP_PATH_PARTS): + return False + ext = os.path.splitext(path)[1].lower() + return bool(ext and ext in gitea.TEXT_EXTENSIONS) + + +def _path_priority(path: str) -> tuple[int, str]: + lowered = path.lower() + for idx, prefix in enumerate(PRIORITY_PREFIXES): + if lowered.startswith(prefix.lower()) or os.path.basename(lowered).startswith(prefix.lower()): + return (idx, path) + return (len(PRIORITY_PREFIXES), path) + + +def list_repo_text_files(repo: str, username: str) -> list[dict]: + owner, name = gitea.parse_repo(repo) + tree = gitea._request( + "GET", + f"/repos/{owner}/{name}/git/trees/HEAD", + username=username, + params={"recursive": "1"}, + ) + max_files = _max_files_per_repo() + max_bytes = _max_file_bytes() + files: list[dict] = [] + for node in tree.get("tree") or []: + if node.get("type") != "blob": + continue + path = node.get("path") or "" + if not _should_index_path(path): + continue + size = int(node.get("size") or 0) + if size > max_bytes: + continue + files.append({"path": path, "sha": node.get("sha"), "size": size}) + files.sort(key=lambda item: _path_priority(item["path"])) + return files[:max_files] + + +def _repo_owner(repo: str) -> str: + owner, _ = gitea.parse_repo(repo) + return owner.lower() + + +def _repo_visibility(private: bool, owner: str) -> str: + if private: + return "personal" + if owner in gitea.MCP_USERS: + return "family" + return "family" + + +def _collection_for_repo(private: bool, owner: str) -> str: + visibility = _repo_visibility(private, owner) + if visibility == "personal": + return qdrant_store.gitea_collection(owner) + return qdrant_store.GITEA_SHARED_COLLECTION + + +def _file_doc_id(repo: str, path: str) -> int: + return zlib.adler32(f"{repo}:{path}".encode("utf-8")) & 0x7FFFFFFF + + +def _file_title(repo: str, path: str) -> str: + return f"{repo}/{path}" + + +def index_file( + repo: str, + path: str, + *, + username: str, + private: bool, + force: bool = False, +) -> dict: + owner = _repo_owner(repo) + conn = get_conn() + row = conn.execute( + "SELECT sha, chunk_count FROM indexed_gitea_files WHERE repo=? AND path=?", + (repo, path), + ).fetchone() + + file_data = gitea.get_file(repo, path, username=username) + sha = file_data.get("sha") or "" + if row and row["sha"] == sha and not force: + return {"repo": repo, "path": path, "skipped": True, "sha": sha} + + text = (file_data.get("content") or "").strip() + if not text: + return {"repo": repo, "path": path, "chunks": 0, "sha": sha} + + header = f"# {_file_title(repo, path)}\n\nSource: gitea:{repo}:{path}\n\n" + chunks = _chunk_text(header + text) + if not chunks: + return {"repo": repo, "path": path, "chunks": 0, "sha": sha} + + visibility = _repo_visibility(private, owner) + collection = _collection_for_repo(private, owner) + doc_id = _file_doc_id(repo, path) + qdrant_store.delete_by_doc(collection, doc_id) + + vectors = embeddings.embed_texts(chunks) + ids = [] + payloads = [] + for i, chunk in enumerate(chunks): + point_id = f"gitea-{repo}-{path}-chunk-{i}" + ids.append(point_id) + payloads.append( + { + "source": "gitea", + "doc_id": doc_id, + "repo": repo, + "path": path, + "chunk_index": i, + "title": _file_title(repo, path), + "text": chunk, + "owner": owner, + "visibility": visibility, + "sha": sha, + } + ) + qdrant_store.upsert_chunks(collection, ids, vectors, payloads) + + stored = qdrant_store.count_by_doc(collection, doc_id) + if stored < len(chunks): + raise RuntimeError( + f"Qdrant upsert incompleto per {repo}/{path}: attesi {len(chunks)} chunk, trovati {stored}" + ) + + conn.execute( + "INSERT INTO indexed_gitea_files(repo,path,sha,owner,visibility,chunk_count,indexed_at)" + " VALUES (?,?,?,?,?,?,datetime('now'))" + " ON CONFLICT(repo,path) DO UPDATE SET" + " sha=excluded.sha, owner=excluded.owner, visibility=excluded.visibility," + " chunk_count=excluded.chunk_count, indexed_at=datetime('now')", + (repo, path, sha, owner, visibility, len(chunks)), + ) + conn.commit() + return { + "repo": repo, + "path": path, + "sha": sha, + "chunks": len(chunks), + "collection": collection, + "visibility": visibility, + } + + +def index_repo( + repo: str, + *, + username: Optional[str] = None, + private: Optional[bool] = None, + force: bool = False, + max_files: Optional[int] = None, +) -> dict: + gitea_user = username or _repo_owner(repo) + if not gitea.is_configured(gitea_user): + raise RuntimeError(f"Gitea non configurato per {gitea_user}") + + if private is None: + owner, name = gitea.parse_repo(repo) + meta = gitea._request("GET", f"/repos/{owner}/{name}", username=gitea_user) + private = bool(meta.get("private")) + + files = list_repo_text_files(repo, gitea_user) + if max_files is not None: + files = files[: max(1, max_files)] + + indexed = 0 + skipped = 0 + errors = 0 + chunks = 0 + for item in files: + try: + result = index_file( + repo, + item["path"], + username=gitea_user, + private=bool(private), + force=force, + ) + if result.get("skipped"): + skipped += 1 + else: + indexed += 1 + chunks += int(result.get("chunks") or 0) + except Exception as exc: + LOGGER.warning("Index gitea %s/%s failed: %s", repo, item.get("path"), exc) + errors += 1 + + return { + "repo": repo, + "files_seen": len(files), + "files_indexed": indexed, + "files_skipped": skipped, + "errors": errors, + "chunks": chunks, + "private": bool(private), + } + + +def index_all(max_files_per_repo: Optional[int] = None) -> dict: + if not _enabled(): + return {"enabled": False, "indexed_files": 0} + + total_indexed = 0 + total_skipped = 0 + total_errors = 0 + total_chunks = 0 + repos_done: list[str] = [] + users = gitea.list_configured_users() or [] + + for username in users: + try: + repos = _repos_for_user(username) + except Exception as exc: + LOGGER.error("Lista repo Gitea fallita per %s: %s", username, exc) + continue + for repo in repos: + try: + result = index_repo( + repo, + username=username, + force=False, + max_files=max_files_per_repo, + ) + repos_done.append(repo) + total_indexed += int(result.get("files_indexed") or 0) + total_skipped += int(result.get("files_skipped") or 0) + total_errors += int(result.get("errors") or 0) + total_chunks += int(result.get("chunks") or 0) + except Exception as exc: + LOGGER.warning("Index repo %s failed (%s): %s", repo, username, exc) + total_errors += 1 + + return { + "enabled": True, + "repos": repos_done, + "files_indexed": total_indexed, + "files_skipped": total_skipped, + "errors": total_errors, + "chunks": total_chunks, + "users": users, + } + + +def gitea_collections_for_user(username: str, is_admin: bool = False) -> list[str]: + cols = [qdrant_store.GITEA_SHARED_COLLECTION, qdrant_store.gitea_collection(username)] + if is_admin: + for user in gitea.MCP_USERS: + cols.append(qdrant_store.gitea_collection(user)) + return list(dict.fromkeys(cols)) + + +def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list: + vectors = embeddings.embed_texts([query]) + collections = gitea_collections_for_user(username, is_admin=is_admin) + hits = qdrant_store.search(collections, vectors[0], limit=limit) + for hit in hits: + hit["source"] = hit.get("source") or "gitea" + return hits + + +def list_indexed_files(limit: int = 30, repo: Optional[str] = None) -> list: + conn = get_conn() + if repo: + rows = conn.execute( + "SELECT * FROM indexed_gitea_files WHERE repo=? ORDER BY indexed_at DESC LIMIT ?", + (repo, limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM indexed_gitea_files ORDER BY indexed_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + +def index_stats() -> dict: + conn = get_conn() + row = conn.execute( + "SELECT COUNT(*), COALESCE(SUM(chunk_count), 0) FROM indexed_gitea_files" + ).fetchone() + files_count = int(row[0] if row else 0) + chunks_meta = int(row[1] if row else 0) + collections = {qdrant_store.GITEA_SHARED_COLLECTION} + for user in gitea.MCP_USERS: + collections.add(qdrant_store.gitea_collection(user)) + qdrant_points = sum(qdrant_store.collection_point_count(c) for c in collections) + return { + "files_indexed": files_count, + "chunks_in_metadata": chunks_meta, + "qdrant_points": qdrant_points, + "collections": {c: qdrant_store.collection_point_count(c) for c in sorted(collections)}, + } diff --git a/services/loogle-mcp/app/knowledge/indexer.py b/services/loogle-mcp/app/knowledge/indexer.py new file mode 100644 index 0000000..3b051d2 --- /dev/null +++ b/services/loogle-mcp/app/knowledge/indexer.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +"""Paperless → Qdrant indexing pipeline.""" + +import logging +from typing import Optional + +LOGGER = logging.getLogger("loogle_mcp.indexer") + +from ..db import get_conn +from . import embeddings, gitea_indexer, paperless, qdrant_store +from .text_chunk import chunk_text as _chunk_text + + +def index_document(doc_id: int, force: bool = False, paperless_user: Optional[str] = None) -> dict: + conn = get_conn() + existing = conn.execute( + "SELECT doc_id FROM indexed_documents WHERE doc_id=?", (doc_id,) + ).fetchone() + if existing and not force: + return {"doc_id": doc_id, "skipped": True} + doc = paperless.get_document(doc_id, username=paperless_user) + text = paperless.download_document_text(doc_id, username=paperless_user) + owner = paperless.document_owner(doc) + visibility = paperless.document_visibility(doc, owner) + chunks = _chunk_text(text) + if not chunks: + return {"doc_id": doc_id, "chunks": 0} + vectors = embeddings.embed_texts(chunks) + collection = qdrant_store.SHARED_COLLECTION if visibility == "family" else qdrant_store.kb_collection(owner) + qdrant_store.delete_by_doc(collection, doc_id) + ids = [] + payloads = [] + for i, chunk in enumerate(chunks): + point_id = f"doc-{doc_id}-chunk-{i}" + ids.append(point_id) + payloads.append( + { + "doc_id": doc_id, + "chunk_index": i, + "title": doc.get("title") or f"Documento {doc_id}", + "text": chunk, + "owner": owner, + "visibility": visibility, + } + ) + qdrant_store.upsert_chunks(collection, ids, vectors, payloads) + conn.execute( + "INSERT INTO indexed_documents(doc_id,title,owner,visibility,chunk_count,indexed_at)" + " VALUES (?,?,?,?,?,datetime('now'))" + " ON CONFLICT(doc_id) DO UPDATE SET" + " title=excluded.title, owner=excluded.owner, visibility=excluded.visibility," + " chunk_count=excluded.chunk_count, indexed_at=datetime('now')", + (doc_id, doc.get("title"), owner, visibility, len(chunks)), + ) + conn.commit() + return {"doc_id": doc_id, "chunks": len(chunks), "collection": collection} + + +def index_all(max_pages: int = 20) -> dict: + indexed = 0 + errors = 0 + seen: set[int] = set() + users = paperless.list_configured_users() or ["daniele"] + for paperless_user in users: + page = 1 + while page <= max_pages: + try: + data = paperless.list_documents(page=page, page_size=25, username=paperless_user) + except Exception as exc: + LOGGER.error("Paperless list failed for %s: %s", paperless_user, exc) + break + results = data.get("results") or [] + if not results: + break + for doc in results: + doc_id = doc["id"] + if doc_id in seen: + continue + seen.add(doc_id) + try: + index_document(doc_id, paperless_user=paperless_user) + indexed += 1 + except Exception as exc: + LOGGER.warning("Index doc %s failed (%s): %s", doc_id, paperless_user, exc) + errors += 1 + if not data.get("next"): + break + page += 1 + return {"indexed": indexed, "errors": errors, "users": users} + + +def index_context_snippet(username: str, project_id: str, text: str, title: str) -> None: + chunks = _chunk_text(text) + if not chunks: + return + vectors = embeddings.embed_texts(chunks) + collection = qdrant_store.ctx_collection(username) + doc_key = f"ctx-{username}-{project_id}" + qdrant_store.delete_by_doc(collection, hash(doc_key) % (2**31)) + ids = [] + payloads = [] + for i, chunk in enumerate(chunks): + point_id = f"{doc_key}-chunk-{i}" + ids.append(point_id) + payloads.append( + { + "doc_id": hash(doc_key) % (2**31), + "project_id": project_id, + "chunk_index": i, + "title": title, + "text": chunk, + "owner": username, + "visibility": "personal", + } + ) + qdrant_store.upsert_chunks(collection, ids, vectors, payloads) + + +def search_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list: + vectors = embeddings.embed_texts([query]) + collections = [ + qdrant_store.SHARED_COLLECTION, + qdrant_store.kb_collection(username), + qdrant_store.ctx_collection(username), + ] + collections.extend(gitea_indexer.gitea_collections_for_user(username, is_admin=is_admin)) + collections.append(qdrant_store.APPS_SHARED_COLLECTION) + if is_admin: + for u in ("daniele", "lucia", "davide", "luca"): + collections.append(qdrant_store.kb_collection(u)) + collections = list(dict.fromkeys(collections)) + hits = qdrant_store.search(collections, vectors[0], limit=limit) + for hit in hits: + if hit.get("repo") and not hit.get("source"): + hit["source"] = "gitea" + elif hit.get("source") in ("irrigazione", "turni"): + hit["source_type"] = "apps" + elif hit.get("doc_id") and not hit.get("source"): + hit["source"] = "paperless" + return hits + + +def search_gitea_knowledge(username: str, query: str, limit: int = 8, is_admin: bool = False) -> list: + return gitea_indexer.search_gitea_knowledge(username, query, limit=limit, is_admin=is_admin) + + +def search_context(username: str, query: str, limit: int = 8) -> list: + vectors = embeddings.embed_texts([query]) + return qdrant_store.search([qdrant_store.ctx_collection(username)], vectors[0], limit=limit) + + +def list_recent_documents(limit: int = 20) -> list: + rows = get_conn().execute( + "SELECT * FROM indexed_documents ORDER BY indexed_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] diff --git a/services/loogle-mcp/app/knowledge/paperless.py b/services/loogle-mcp/app/knowledge/paperless.py new file mode 100644 index 0000000..c2da3c6 --- /dev/null +++ b/services/loogle-mcp/app/knowledge/paperless.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +"""Paperless-ngx REST API client — supporto token per utente MCP.""" + +import json +import logging +import os +from typing import Optional + +import httpx + +LOGGER = logging.getLogger("loogle_mcp.paperless") + +PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/") +MCP_USERS = ("daniele", "lucia", "davide", "luca") +_tokens_cache: Optional[dict[str, str]] = None + + +def _load_tokens() -> dict[str, str]: + """Carica token Paperless per utente MCP. + + Priorità per ogni utente: + 1. PAPERLESS_API_TOKEN_{USERNAME} (es. PAPERLESS_API_TOKEN_LUCIA) + 2. Chiavi in PAPERLESS_API_TOKENS JSON (es. {"daniele":"...", "lucia":"..."}) + 3. PAPERLESS_API_TOKEN globale (fallback per tutti, tipico account admin) + """ + global _tokens_cache + if _tokens_cache is not None: + return _tokens_cache + + tokens: dict[str, str] = {} + json_map = os.environ.get("PAPERLESS_API_TOKENS", "").strip() + if json_map: + try: + parsed = json.loads(json_map) + if isinstance(parsed, dict): + tokens.update({k.lower(): v for k, v in parsed.items() if v}) + except json.JSONDecodeError: + LOGGER.warning("PAPERLESS_API_TOKENS non è JSON valido") + + fallback = os.environ.get("PAPERLESS_API_TOKEN", "").strip() + for user in MCP_USERS: + env_key = f"PAPERLESS_API_TOKEN_{user.upper()}" + token = os.environ.get(env_key, "").strip() + if token: + tokens[user] = token + elif user not in tokens and fallback: + tokens[user] = fallback + + if not tokens and fallback: + tokens["daniele"] = fallback + + _tokens_cache = tokens + return tokens + + +def list_configured_users() -> list[str]: + return list(_load_tokens().keys()) + + +def _headers(username: Optional[str] = None) -> dict: + tokens = _load_tokens() + if not tokens: + raise RuntimeError( + "Nessun token Paperless configurato. " + "Imposta PAPERLESS_API_TOKEN o PAPERLESS_API_TOKEN_{USER} in .env" + ) + user = (username or "daniele").lower() + token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values())) + return {"Authorization": f"Token {token}"} + + +def list_documents( + page: int = 1, + page_size: int = 25, + ordering: str = "-modified", + username: Optional[str] = None, +) -> dict: + with httpx.Client(timeout=60.0, verify=True) as client: + resp = client.get( + f"{PAPERLESS_URL}/api/documents/", + headers=_headers(username), + params={"page": page, "page_size": page_size, "ordering": ordering}, + ) + resp.raise_for_status() + return resp.json() + + +def get_document(doc_id: int, username: Optional[str] = None) -> dict: + with httpx.Client(timeout=60.0, verify=True) as client: + resp = client.get( + f"{PAPERLESS_URL}/api/documents/{doc_id}/", + headers=_headers(username), + ) + resp.raise_for_status() + return resp.json() + + +def download_document_text(doc_id: int, username: Optional[str] = None) -> str: + doc = get_document(doc_id, username=username) + content = (doc.get("content") or "").strip() + if content: + return content + title = doc.get("title") or f"Documento {doc_id}" + return f"# {title}\n\n(Nessun testo OCR disponibile)" + + +def document_visibility(doc: dict, owner_username: str) -> str: + tags = doc.get("tags") or [] + tag_names = [] + for t in tags: + if isinstance(t, dict): + tag_names.append((t.get("name") or "").lower()) + elif isinstance(t, int): + continue + else: + tag_names.append(str(t).lower()) + if any(t in ("personal", "privato", "private") for t in tag_names): + return "personal" + if any(t in ("admin-only", "admin") for t in tag_names): + return "admin" + return "family" + + +def document_owner(doc: dict, default: str = "daniele") -> str: + owner = doc.get("owner") + if isinstance(owner, int): + pass + owner_username = doc.get("owner_username") or doc.get("owner_name") + if isinstance(owner_username, str): + name = owner_username.lower() + for user in MCP_USERS: + if user in name: + return user + correspondent = doc.get("correspondent") + if isinstance(correspondent, dict): + name = (correspondent.get("name") or "").lower() + for user in MCP_USERS: + if user in name: + return user + return default diff --git a/services/loogle-mcp/app/knowledge/qdrant_store.py b/services/loogle-mcp/app/knowledge/qdrant_store.py new file mode 100644 index 0000000..990c4f5 --- /dev/null +++ b/services/loogle-mcp/app/knowledge/qdrant_store.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +"""Vector store — Qdrant remoto (DS920) con fallback SQLite locale su Pi ARM.""" + +import json +import logging +import math +import os +import sqlite3 +import uuid +from typing import Optional + +LOGGER = logging.getLogger("loogle_mcp.vector_store") +VECTOR_SIZE = 768 +_local = sqlite3.connect(":memory:", check_same_thread=False) # placeholder +_qdrant_client = None +_qdrant_checked = False +_use_fallback = False + + +def _fallback_path() -> str: + return os.environ.get("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db") + + +def _fallback_conn() -> sqlite3.Connection: + path = _fallback_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + conn = sqlite3.connect(path, timeout=30) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS vectors ( + id TEXT PRIMARY KEY, + collection TEXT NOT NULL, + vector TEXT NOT NULL, + payload TEXT NOT NULL + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_vectors_collection ON vectors(collection)") + conn.commit() + return conn + + +def _get_qdrant(): + global _qdrant_client, _qdrant_checked, _use_fallback + if _qdrant_checked: + return None if _use_fallback else _qdrant_client + _qdrant_checked = True + url = os.environ.get("QDRANT_URL", "").strip() + if not url: + _use_fallback = True + LOGGER.warning("QDRANT_URL non impostato — fallback SQLite") + return None + try: + from qdrant_client import QdrantClient + from qdrant_client.http import models as qm + + client = QdrantClient(url=url, timeout=60) + client.get_collections() + _qdrant_client = client + globals()["qm"] = qm + LOGGER.info("Qdrant connesso: %s", url) + return client + except Exception as exc: + _use_fallback = True + LOGGER.warning("Qdrant non disponibile (%s) — fallback SQLite", exc) + return None + + +def ensure_collection(name: str, vector_size: int = VECTOR_SIZE) -> None: + client = _get_qdrant() + if client is None: + return + from qdrant_client.http import models as qm + + names = {c.name for c in client.get_collections().collections} + if name in names: + return + client.create_collection( + collection_name=name, + vectors_config=qm.VectorParams(size=vector_size, distance=qm.Distance.COSINE), + ) + + +def kb_collection(username: str) -> str: + return f"kb_personal_{username}" + + +def ctx_collection(username: str) -> str: + return f"ctx_{username}" + + +SHARED_COLLECTION = "kb_shared_family" +GITEA_SHARED_COLLECTION = "gitea_shared_family" +APPS_SHARED_COLLECTION = "apps_shared_family" + + +def gitea_collection(username: str) -> str: + return f"gitea_personal_{username}" + + +def _point_id(name: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, name)) + + +def _cosine(a: list[float], b: list[float]) -> float: + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) or 1.0 + nb = math.sqrt(sum(x * x for x in b)) or 1.0 + return dot / (na * nb) + + +def upsert_chunks( + collection: str, + ids: list[str], + vectors: list[list[float]], + payloads: list[dict], +) -> None: + if not vectors: + return + ensure_collection(collection, len(vectors[0])) + client = _get_qdrant() + if client is not None: + from qdrant_client.http import models as qm + + points = [ + qm.PointStruct(id=_point_id(pid), vector=vec, payload=payload) + for pid, vec, payload in zip(ids, vectors, payloads) + ] + client.upsert(collection_name=collection, points=points) + return + conn = _fallback_conn() + for pid, vec, payload in zip(ids, vectors, payloads): + conn.execute( + "INSERT OR REPLACE INTO vectors(id,collection,vector,payload) VALUES (?,?,?,?)", + (_point_id(pid), collection, json.dumps(vec), json.dumps(payload, ensure_ascii=False)), + ) + conn.commit() + + +def delete_by_doc(collection: str, doc_id: int) -> None: + client = _get_qdrant() + if client is not None: + from qdrant_client.http import models as qm + + ensure_collection(collection) + client.delete( + collection_name=collection, + points_selector=qm.FilterSelector( + filter=qm.Filter( + must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))] + ) + ), + ) + return + conn = _fallback_conn() + rows = conn.execute("SELECT id,payload FROM vectors WHERE collection=?", (collection,)).fetchall() + for row_id, payload_raw in rows: + payload = json.loads(payload_raw) + if payload.get("doc_id") == doc_id: + conn.execute("DELETE FROM vectors WHERE id=?", (row_id,)) + conn.commit() + + +def count_by_doc(collection: str, doc_id: int) -> int: + client = _get_qdrant() + if client is not None: + from qdrant_client.http import models as qm + + ensure_collection(collection) + result = client.count( + collection_name=collection, + count_filter=qm.Filter( + must=[qm.FieldCondition(key="doc_id", match=qm.MatchValue(value=doc_id))] + ), + exact=True, + ) + return int(result.count) + conn = _fallback_conn() + rows = conn.execute("SELECT payload FROM vectors WHERE collection=?", (collection,)).fetchall() + count = 0 + for (payload_raw,) in rows: + payload = json.loads(payload_raw) + if payload.get("doc_id") == doc_id: + count += 1 + return count + + +def collection_point_count(collection: str) -> int: + client = _get_qdrant() + if client is not None: + try: + info = client.get_collection(collection) + return int(info.points_count or 0) + except Exception: + return 0 + conn = _fallback_conn() + row = conn.execute( + "SELECT COUNT(*) FROM vectors WHERE collection=?", (collection,) + ).fetchone() + return int(row[0] if row else 0) + + +def search( + collections: list[str], + vector: list[float], + limit: int = 8, + visibility_filter: Optional[dict] = None, +) -> list[dict]: + results: list[dict] = [] + client = _get_qdrant() + if client is not None: + from qdrant_client.http import models as qm + + for collection in collections: + ensure_collection(collection, len(vector)) + flt = None + if visibility_filter: + must = [ + qm.FieldCondition(key=k, match=qm.MatchValue(value=v)) + for k, v in visibility_filter.items() + ] + if must: + flt = qm.Filter(must=must) + hits = client.search( + collection_name=collection, + query_vector=vector, + limit=limit, + query_filter=flt, + ) + for hit in hits: + payload = dict(hit.payload or {}) + payload["score"] = hit.score + payload["collection"] = collection + results.append(payload) + else: + conn = _fallback_conn() + for collection in collections: + rows = conn.execute( + "SELECT vector,payload FROM vectors WHERE collection=?", (collection,) + ).fetchall() + for vec_raw, payload_raw in rows: + payload = dict(json.loads(payload_raw)) + if visibility_filter: + if any(payload.get(k) != v for k, v in visibility_filter.items()): + continue + score = _cosine(vector, json.loads(vec_raw)) + payload["score"] = score + payload["collection"] = collection + results.append(payload) + results.sort(key=lambda x: x.get("score", 0), reverse=True) + return results[:limit] diff --git a/services/loogle-mcp/app/knowledge/text_chunk.py b/services/loogle-mcp/app/knowledge/text_chunk.py new file mode 100644 index 0000000..ca40217 --- /dev/null +++ b/services/loogle-mcp/app/knowledge/text_chunk.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""Utility condivise per chunking testo RAG.""" + +import re + +CHUNK_SIZE = 900 +CHUNK_OVERLAP = 150 + + +def chunk_text(text: str) -> list[str]: + text = re.sub(r"\n{3,}", "\n\n", text.strip()) + if len(text) <= CHUNK_SIZE: + return [text] if text else [] + chunks = [] + start = 0 + while start < len(text): + end = min(len(text), start + CHUNK_SIZE) + chunks.append(text[start:end]) + if end >= len(text): + break + start = max(end - CHUNK_OVERLAP, start + 1) + return chunks diff --git a/services/loogle-mcp/app/knowledge/thermal.py b/services/loogle-mcp/app/knowledge/thermal.py new file mode 100644 index 0000000..3f59144 --- /dev/null +++ b/services/loogle-mcp/app/knowledge/thermal.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +"""Thermal / load gate per proteggere il DS920 durante gli embedding Ollama. + +Legge temperatura CPU e load average dal NAS e regola pause/keep_alive. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import time +from typing import Optional + +import httpx + +LOGGER = logging.getLogger("loogle_mcp.thermal") + +# Soft: rallenta. Hard: pausa (raro se il profilo lento tiene). +# Profilo "lento ma regolare": anticipare soft, cap load basso. +DEFAULT_SOFT_C = 58.0 +DEFAULT_HARD_C = 70.0 +DEFAULT_CPU_TARGET_PCT = 40.0 # load1 <= nproc * 0.40 + + +def _float_env(name: str, default: float) -> float: + try: + return float(os.environ.get(name, str(default))) + except ValueError: + return default + + +def soft_temp_c() -> float: + return _float_env("THERMAL_TEMP_SOFT_C", DEFAULT_SOFT_C) + + +def hard_temp_c() -> float: + return _float_env("THERMAL_TEMP_HARD_C", DEFAULT_HARD_C) + + +def cpu_target_pct() -> float: + return _float_env("THERMAL_CPU_TARGET_PCT", DEFAULT_CPU_TARGET_PCT) + + +def enabled() -> bool: + flag = os.environ.get("THERMAL_GATE_ENABLED", "yes").strip().lower() + return flag not in ("0", "false", "no", "off") + + +def _read_via_http() -> Optional[dict]: + url = os.environ.get("DS920_THERMAL_URL", "").strip() + if not url: + # default probe se non configurato + url = os.environ.get( + "DS920_THERMAL_URL_DEFAULT", + "http://192.168.128.100:9191/thermal", + ).strip() + try: + with httpx.Client(timeout=3.0) as client: + resp = client.get(url) + if resp.status_code != 200: + return None + data = resp.json() + if isinstance(data, dict) and "cpu_temp_c" in data: + return data + except Exception as exc: + LOGGER.debug("Thermal HTTP probe fallita: %s", exc) + return None + + +def _read_via_ssh() -> Optional[dict]: + host = os.environ.get("DS920_SSH_HOST", "192.168.128.100").strip() + user = os.environ.get("DS920_SSH_USER", "daniely").strip() + key = os.environ.get("DS920_SSH_KEY", "").strip() + if not host: + return None + remote = ( + "python3 -c \"import json,os;" + "b='/sys/class/hwmon/hwmon0';" + "t=[int(open(f'{b}/'+n).read())/1000 for n in sorted(os.listdir(b)) " + "if n.startswith('temp') and n.endswith('_input')];" + "l=os.getloadavg();" + "print(json.dumps({'cpu_temp_c':max(t) if t else None," + "'load1':l[0],'load5':l[1],'nproc':os.cpu_count() or 4}))\"" + ) + cmd = [ + "ssh", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", + ] + if key and os.path.isfile(key): + cmd.extend(["-i", key]) + cmd.append(f"{user}@{host}") + cmd.append(remote) + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=12, text=True) + data = json.loads(out.strip()) + if isinstance(data, dict) and data.get("cpu_temp_c") is not None: + return data + except Exception as exc: + LOGGER.debug("Thermal SSH probe fallita: %s", exc) + return None + + +def read_status() -> Optional[dict]: + """Ritorna {cpu_temp_c, load1, load5?, nproc} oppure None se non raggiungibile.""" + data = _read_via_http() + if data: + data["source"] = "http" + return data + data = _read_via_ssh() + if data: + data["source"] = "ssh" + return data + return None + + +def load_over_target(status: dict) -> bool: + load1 = float(status.get("load1") or 0) + nproc = float(status.get("nproc") or 4) + target = nproc * (cpu_target_pct() / 100.0) + return load1 > target + + +def suggested_keep_alive(status: Optional[dict]) -> int: + """Secondi keep_alive Ollama. + + Il container resta sempre acceso: non si fa unload per throttling termico + (evita cicli load/unload). Si scarica solo se OLLAMA_UNLOAD_ON_HARD=yes. + """ + cool = int(_float_env("OLLAMA_KEEP_ALIVE_COOL", 300)) + default = int(_float_env("OLLAMA_KEEP_ALIVE_DEFAULT", 120)) + if not status or status.get("cpu_temp_c") is None: + return default + temp = float(status["cpu_temp_c"]) + unload = os.environ.get("OLLAMA_UNLOAD_ON_HARD", "no").strip().lower() + if temp >= hard_temp_c() and unload in ("1", "true", "yes", "on"): + return 0 + return cool + + +def suggested_delay_s(status: Optional[dict]) -> float: + """Duty-cycle lento: delay base sempre presente; cresce con temp/load.""" + base = _float_env("OLLAMA_EMBED_DELAY_S", 8.0) + if not status or status.get("cpu_temp_c") is None: + return max(base, 5.0) + temp = float(status["cpu_temp_c"]) + hard = hard_temp_c() + soft = soft_temp_c() + if temp >= hard: + return max(base, 45.0) + if temp >= soft: + # soft→hard: ~base*1.5 … ~35s (continuo, non on/off) + ratio = (temp - soft) / max(hard - soft, 1.0) + return max(base, base * 1.5 + ratio * 25.0) + if load_over_target(status): + return max(base, base * 2.0) + if temp >= soft - 4: + return max(base, base * 1.25) + return base + + +def wait_for_headroom(*, context: str = "embed") -> Optional[dict]: + """Attende headroom: HARD = pausa lunga; altrimenti delay proporzionale. + + Obiettivo: ritmo lento e regolare, evitando oscillazioni start/stop. + """ + if not enabled(): + return None + + poll = _float_env("THERMAL_POLL_S", 30.0) + hard = hard_temp_c() + soft = soft_temp_c() + # Riprendi solo quando sotto soft - 2°C (isteresi anti-oscillazione) + resume_below = soft - _float_env("THERMAL_RESUME_MARGIN_C", 2.0) + + while True: + status = read_status() + if status is None: + LOGGER.warning("Thermal gate: probe non disponibile — delay conservativo") + time.sleep(max(suggested_delay_s(None), 5.0)) + return None + + temp = float(status.get("cpu_temp_c") or 0) + load1 = float(status.get("load1") or 0) + + if temp >= hard: + LOGGER.warning( + "Thermal gate [%s]: PAUSA HARD temp=%.1f°C (tetto=%.0f°C resume<=%.0f°C " + "load1=%.2f) — riprovo tra %.0fs", + context, + temp, + hard, + resume_below, + load1, + poll, + ) + time.sleep(poll) + # Isteresi: resta in pausa finché non scende sotto soft + while True: + cooled = read_status() + if cooled is None: + time.sleep(poll) + continue + t2 = float(cooled.get("cpu_temp_c") or 0) + if t2 <= resume_below and not load_over_target(cooled): + LOGGER.info( + "Thermal gate [%s]: ripresa dopo HARD (temp=%.1f°C)", + context, + t2, + ) + status = cooled + break + time.sleep(poll) + # dopo ripresa applica comunque un delay soft prima dell'embed + time.sleep(suggested_delay_s(status)) + return status + + if temp >= soft or load_over_target(status): + delay = suggested_delay_s(status) + LOGGER.info( + "Thermal gate [%s]: rallento temp=%.1f°C load1=%.2f — delay %.1fs (source=%s)", + context, + temp, + load1, + delay, + status.get("source"), + ) + time.sleep(delay) + again = read_status() + if again and float(again.get("cpu_temp_c") or 0) >= hard: + status = again + continue + return again or status + + # Zona fredda: piccolo delay fisso per duty-cycle regolare + cool_delay = _float_env("OLLAMA_EMBED_COOL_DELAY_S", 0.0) + if cool_delay > 0: + time.sleep(cool_delay) + return status \ No newline at end of file diff --git a/services/loogle-mcp/app/main.py b/services/loogle-mcp/app/main.py new file mode 100644 index 0000000..40f225d --- /dev/null +++ b/services/loogle-mcp/app/main.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +"""Loogle MCP Hub — gateway FastAPI + OAuth + MCP Streamable HTTP.""" + +import json +import logging +import os +import secrets +from typing import Optional + +from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from pydantic import BaseModel + +from . import audit, auth, jwt_utils, oauth +from .db import get_conn, init_db +from .mcp import server as mcp_server + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") +LOGGER = logging.getLogger("loogle_mcp.main") + +app = FastAPI(title="Loogle MCP Hub", docs_url=None, redoc_url=None) +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://claude.ai", + "https://chatgpt.com", + "https://chat.openai.com", + ], + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["*"], +) +STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") + + +@app.on_event("startup") +def on_startup() -> None: + init_db() + auth.ensure_sessions_table() + auth.ensure_family_users() + oauth.ensure_default_client() + if not os.environ.get("MCP_JWT_SECRET", "").strip(): + secret = secrets.token_urlsafe(48) + os.environ["MCP_JWT_SECRET"] = secret + LOGGER.warning("MCP_JWT_SECRET generato — salvalo in .env: %s", secret) + + +# ------------------------------------------------------------------ Health + +@app.get("/health") +def health(): + return {"ok": True, "service": "loogle-mcp", "port": int(os.environ.get("MCP_PORT", "8700"))} + + +# ------------------------------------------------------------------ OAuth metadata + +@app.get("/.well-known/oauth-authorization-server") +def oauth_metadata(): + return oauth.authorization_server_metadata() + + +@app.get("/.well-known/oauth-protected-resource") +def protected_resource(): + return oauth.protected_resource_metadata() + + +@app.get("/.well-known/oauth-protected-resource/mcp") +def protected_resource_mcp(): + return oauth.protected_resource_metadata() + + +@app.get("/.well-known/openid-configuration") +def openid_configuration(): + """Fallback discovery usato da Claude se oauth-authorization-server non basta.""" + return oauth.authorization_server_metadata() + + +# ------------------------------------------------------------------ OAuth endpoints + +class RegisterBody(BaseModel): + client_name: str + redirect_uris: list[str] + + +@app.post("/oauth/register") +def oauth_register(body: RegisterBody): + try: + return oauth.register_client(body.client_name, body.redirect_uris) + except HTTPException as exc: + return JSONResponse( + status_code=exc.status_code, + content={"error": "invalid_client_metadata", "error_description": str(exc.detail)}, + ) + + +# Alias root-level OAuth (Claude/ChatGPT fallback se la discovery RFC 8414 fallisce) +@app.post("/register") +def oauth_register_root(body: RegisterBody): + return oauth_register(body) + + +@app.get("/oauth/authorize") +def oauth_authorize_get( + response_type: str, + client_id: str, + redirect_uri: str, + scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write", + state: str = "", + code_challenge: Optional[str] = None, + code_challenge_method: Optional[str] = None, +): + if response_type != "code": + raise HTTPException(400, "response_type must be code") + html = _login_form(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method) + return HTMLResponse(html) + + +@app.post("/oauth/authorize") +def oauth_authorize_post( + request: Request, + client_id: str = Form(...), + redirect_uri: str = Form(...), + scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"), + state: str = Form(""), + code_challenge: Optional[str] = Form(None), + code_challenge_method: Optional[str] = Form(None), + username: str = Form(...), + password: str = Form(...), +): + ip = request.client.host if request.client else "?" + auth.throttle(ip) + user = auth.authenticate(username, password) + if not user: + auth.record_attempt(ip) + html = _login_form( + client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, + error="Credenziali non valide", + ) + return HTMLResponse(html, status_code=401) + url = oauth.build_authorize_redirect( + client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, user["id"] + ) + return RedirectResponse(url, status_code=302) + + +@app.get("/authorize") +def oauth_authorize_get_root( + response_type: str, + client_id: str, + redirect_uri: str, + scope: str = "context:read context:write knowledge:read knowledge:write gitea:read gitea:write", + state: str = "", + code_challenge: Optional[str] = None, + code_challenge_method: Optional[str] = None, +): + return oauth_authorize_get( + response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method + ) + + +@app.post("/authorize") +def oauth_authorize_post_root( + request: Request, + client_id: str = Form(...), + redirect_uri: str = Form(...), + scope: str = Form("context:read context:write knowledge:read knowledge:write gitea:read gitea:write"), + state: str = Form(""), + code_challenge: Optional[str] = Form(None), + code_challenge_method: Optional[str] = Form(None), + username: str = Form(...), + password: str = Form(...), +): + return oauth_authorize_post( + request, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, username, password + ) + + +@app.post("/oauth/token") +async def oauth_token(request: Request): + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + body = await request.json() + else: + form = await request.form() + body = dict(form) + grant_type = body.get("grant_type") + client_id = body.get("client_id") or os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public") + if grant_type == "authorization_code": + return oauth.exchange_code( + body.get("code", ""), + client_id, + body.get("redirect_uri", ""), + body.get("code_verifier"), + ) + if grant_type == "refresh_token": + return oauth.refresh_access_token(body.get("refresh_token", ""), client_id) + raise HTTPException(400, "grant_type non supportato") + + +@app.post("/token") +async def oauth_token_root(request: Request): + return await oauth_token(request) + + +# ------------------------------------------------------------------ MCP endpoint + +@app.post("/mcp") +async def mcp_post(request: Request): + auth_header = request.headers.get("authorization", "") + claims = oauth.bearer_claims_from_header(auth_header) + try: + payload = await request.json() + except Exception: + raise HTTPException(400, "JSON non valido") + if isinstance(payload, list): + responses = mcp_server.handle_batch(payload, claims) + return JSONResponse(responses) + response = mcp_server.handle_message(payload, claims) + if not claims and payload.get("method") not in ("initialize", "notifications/initialized", "ping"): + return JSONResponse(response, status_code=401, headers=_auth_challenge_headers()) + return JSONResponse(response) + + +@app.get("/mcp") +def mcp_get(): + return JSONResponse( + {"error": "Use POST for MCP JSON-RPC"}, + status_code=405, + headers=_auth_challenge_headers(), + ) + + +def _auth_challenge_headers() -> dict: + base = jwt_utils.base_url() + resource_metadata = f"{base}/.well-known/oauth-protected-resource/mcp" + return { + "WWW-Authenticate": f'Bearer realm="mcp", resource_metadata="{resource_metadata}"', + } + + +# ------------------------------------------------------------------ Dashboard web + +class LoginBody(BaseModel): + username: str + password: str + + +class PasswordBody(BaseModel): + old_password: str + new_password: str + + +class RevokeBody(BaseModel): + refresh_token: str + + +@app.post("/api/login") +def api_login(body: LoginBody, request: Request, response: Response): + ip = request.client.host if request.client else "?" + auth.throttle(ip) + user = auth.authenticate(body.username.strip(), body.password) + if not user: + auth.record_attempt(ip) + raise HTTPException(401, "Credenziali non valide") + import datetime + token = secrets.token_urlsafe(32) + expires = ( + datetime.datetime.utcnow() + datetime.timedelta(days=auth.SESSION_DAYS) + ).strftime("%Y-%m-%d %H:%M:%S") + get_conn().execute( + "INSERT INTO sessions(token,user_id,expires_at) VALUES (?,?,?)", + (token, user["id"], expires), + ) + get_conn().commit() + response.set_cookie("mcp_session", token, max_age=auth.SESSION_DAYS * 86400, httponly=True, samesite="lax", path="/") + return {"ok": True, "user": {"username": user["username"], "is_admin": user["is_admin"]}} + + +@app.post("/api/logout") +def api_logout(request: Request, response: Response): + token = request.cookies.get("mcp_session", "") + if token: + get_conn().execute("DELETE FROM sessions WHERE token=?", (token,)) + get_conn().commit() + response.delete_cookie("mcp_session", path="/") + return {"ok": True} + + +@app.get("/api/me") +def api_me(user=Depends(auth.current_user_from_cookie)): + return user + + +@app.post("/api/password") +def api_password(body: PasswordBody, user=Depends(auth.current_user_from_cookie)): + if len(body.new_password.strip()) < 6: + raise HTTPException(400, "La nuova password deve avere almeno 6 caratteri") + if not auth.change_password(user["id"], body.old_password, body.new_password): + raise HTTPException(400, "Password attuale errata") + return {"ok": True} + + +@app.get("/api/projects") +def api_projects(user=Depends(auth.current_user_from_cookie)): + from .context import store as context_store + return context_store.list_projects(user["username"]) + + +@app.get("/api/audit") +def api_audit(limit: int = 100, user=Depends(auth.current_user_from_cookie)): + if user["is_admin"]: + return audit.list_audit(limit=min(limit, 500)) + return audit.list_audit(limit=min(limit, 200), username=user["username"]) + + +@app.post("/api/admin/revoke-refresh") +def api_revoke_refresh(body: RevokeBody, _=Depends(auth.require_admin)): + jwt_utils.revoke_refresh_token(body.refresh_token) + return {"ok": True} + + +@app.get("/dashboard") +def dashboard_page(): + path = os.path.join(STATIC_DIR, "dashboard.html") + return HTMLResponse(open(path, encoding="utf-8").read()) + + +@app.get("/") +def root(): + return RedirectResponse("/dashboard") + + +def _login_form( + client_id: str, + redirect_uri: str, + scope: str, + state: str, + code_challenge: Optional[str], + code_challenge_method: Optional[str], + error: str = "", +) -> str: + err = f'

{error}

' if error else "" + return f""" + +Loogle MCP — Login + +

Loogle MCP Hub

+

Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.

+
{err} +
+ + + + + + + + + +
+

Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.

+
""" diff --git a/services/loogle-mcp/app/mcp/__init__.py b/services/loogle-mcp/app/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/loogle-mcp/app/mcp/server.py b/services/loogle-mcp/app/mcp/server.py new file mode 100644 index 0000000..d286463 --- /dev/null +++ b/services/loogle-mcp/app/mcp/server.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""MCP JSON-RPC handler (Streamable HTTP compatible).""" + +import json +import logging +from typing import Any, Optional + +from . import tools + +LOGGER = logging.getLogger("loogle_mcp.server") +PROTOCOL_VERSION = "2024-11-05" + + +def _error(req_id: Any, code: int, message: str) -> dict: + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} + + +def _result(req_id: Any, result: dict) -> dict: + return {"jsonrpc": "2.0", "id": req_id, "result": result} + + +def handle_message(body: dict, claims: Optional[dict]) -> dict: + method = body.get("method") + req_id = body.get("id") + params = body.get("params") or {} + + if method == "initialize": + return _result( + req_id, + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}, "resources": {}}, + "serverInfo": {"name": "loogle-mcp", "version": "1.0.0"}, + }, + ) + + if method == "notifications/initialized": + return _result(req_id, {}) + + if method == "ping": + return _result(req_id, {}) + + if not claims: + return _error(req_id, -32001, "Autenticazione richiesta (Bearer token OAuth)") + + if method == "tools/list": + return _result(req_id, {"tools": tools.tool_definitions()}) + + if method == "tools/call": + name = params.get("name") + arguments = params.get("arguments") or {} + try: + tool_result = tools.call_tool(name, arguments, claims) + return _result(req_id, tool_result) + except PermissionError as exc: + return _error(req_id, -32003, str(exc)) + except FileNotFoundError as exc: + return _error(req_id, -32004, str(exc)) + except Exception as exc: + LOGGER.exception("Tool %s failed", name) + return _error(req_id, -32000, str(exc)) + + if method == "resources/list": + return _result(req_id, {"resources": tools.list_resources(claims)}) + + if method == "resources/read": + uri = params.get("uri") + try: + resource = tools.read_resource(uri, claims) + return _result(req_id, {"contents": [resource]}) + except FileNotFoundError as exc: + return _error(req_id, -32004, str(exc)) + + return _error(req_id, -32601, f"Metodo non supportato: {method}") + + +def handle_batch(messages: list, claims: Optional[dict]) -> list: + return [handle_message(msg, claims) for msg in messages if isinstance(msg, dict)] diff --git a/services/loogle-mcp/app/mcp/tools.py b/services/loogle-mcp/app/mcp/tools.py new file mode 100644 index 0000000..f069c17 --- /dev/null +++ b/services/loogle-mcp/app/mcp/tools.py @@ -0,0 +1,962 @@ +# -*- coding: utf-8 -*- +"""MCP tool definitions and handlers.""" + +import json +from typing import Any, Optional + +from .. import audit +from ..context import store as context_store +from ..jwt_utils import has_scope +from ..knowledge import apps_indexer, gitea, gitea_indexer, indexer, paperless +from ..integrations import casa, homeassistant, irrigazione, turni + + +def tool_definitions() -> list[dict]: + return [ + { + "name": "ping", + "description": "Verifica che il server MCP Loogle risponda", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "whoami", + "description": "Restituisce l'utente autenticato e gli scope attivi", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "list_projects", + "description": "Elenca i progetti dell'utente corrente", + "inputSchema": { + "type": "object", + "properties": {"include_archived": {"type": "boolean", "default": False}}, + }, + }, + { + "name": "create_project", + "description": "Crea un nuovo progetto con archivio contesto; opzionalmente collega un repo Gitea", + "inputSchema": { + "type": "object", + "required": ["title"], + "properties": { + "title": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "gitea_repo": { + "type": "string", + "description": "Repository Gitea owner/name (es. daniele/rete)", + }, + "seed_from_gitea": { + "type": "boolean", + "default": False, + "description": "Importa README.md nel context.md se collegato a Gitea", + }, + }, + }, + }, + { + "name": "link_project_repo", + "description": "Collega o scollega un repository Gitea da un progetto esistente", + "inputSchema": { + "type": "object", + "required": ["project_id"], + "properties": { + "project_id": {"type": "string"}, + "gitea_repo": { + "type": "string", + "description": "owner/name da collegare; omit o stringa vuota per scollegare", + }, + "seed_from_gitea": { + "type": "boolean", + "default": False, + "description": "Importa README.md nel context.md (solo se non già importato)", + }, + }, + }, + }, + { + "name": "get_project_context", + "description": "Recupera meta, context.md, sessioni recenti e arricchimento Gitea del progetto", + "inputSchema": { + "type": "object", + "required": ["project_id"], + "properties": { + "project_id": {"type": "string"}, + "session_limit": {"type": "integer", "default": 5}, + "include_gitea": { + "type": "boolean", + "default": True, + "description": "Include README/docs dal repo Gitea collegato", + }, + }, + }, + }, + { + "name": "save_context", + "description": "Salva o appende memoria persistente in un progetto", + "inputSchema": { + "type": "object", + "required": ["project_id", "content"], + "properties": { + "project_id": {"type": "string"}, + "content": {"type": "string"}, + "mode": {"type": "string", "enum": ["append", "replace"], "default": "append"}, + }, + }, + }, + { + "name": "archive_project", + "description": "Archivia o ripristina un progetto", + "inputSchema": { + "type": "object", + "required": ["project_id"], + "properties": { + "project_id": {"type": "string"}, + "archived": {"type": "boolean", "default": True}, + }, + }, + }, + { + "name": "search_context", + "description": "Ricerca semantica nei contesti salvati dell'utente", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 8}, + }, + }, + }, + { + "name": "search_knowledge", + "description": "Ricerca semantica su Paperless, contesti salvati e repository Gitea indicizzati", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 8}, + }, + }, + }, + { + "name": "search_gitea_knowledge", + "description": "Ricerca semantica solo sui file Gitea indicizzati (runbook, markdown, codice)", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 8}, + }, + }, + }, + { + "name": "list_gitea_indexed_files", + "description": "Elenca gli ultimi file Gitea indicizzati nel vector store", + "inputSchema": { + "type": "object", + "properties": { + "repo": {"type": "string", "description": "Filtra per owner/name"}, + "limit": {"type": "integer", "default": 30}, + }, + }, + }, + { + "name": "reindex_gitea_repo", + "description": "Re-indicizza i file testo di un repository Gitea (admin o owner repo)", + "inputSchema": { + "type": "object", + "required": ["repo"], + "properties": { + "repo": {"type": "string", "description": "owner/name, es. daniele/rete"}, + "max_files": { + "type": "integer", + "description": "Limite file per questa esecuzione (default: config globale)", + }, + }, + }, + }, + { + "name": "get_document", + "description": "Recupera il contenuto testuale di un documento Paperless per ID", + "inputSchema": { + "type": "object", + "required": ["doc_id"], + "properties": {"doc_id": {"type": "integer"}}, + }, + }, + { + "name": "list_recent_documents", + "description": "Elenca gli ultimi documenti indicizzati", + "inputSchema": { + "type": "object", + "properties": {"limit": {"type": "integer", "default": 20}}, + }, + }, + { + "name": "reindex_document", + "description": "Re-indicizza un documento Paperless (admin)", + "inputSchema": { + "type": "object", + "required": ["doc_id"], + "properties": {"doc_id": {"type": "integer"}}, + }, + }, + { + "name": "list_repos", + "description": "Elenca i repository Gitea accessibili all'utente corrente", + "inputSchema": { + "type": "object", + "properties": { + "page": {"type": "integer", "default": 1}, + "limit": {"type": "integer", "default": 50}, + }, + }, + }, + { + "name": "get_file", + "description": "Legge un file (o elenca una directory) da un repository Gitea", + "inputSchema": { + "type": "object", + "required": ["repo", "path"], + "properties": { + "repo": {"type": "string", "description": "owner/name, es. daniele/rete"}, + "path": {"type": "string", "description": "Percorso nel repo, es. ha/RUNBOOK-failover.md"}, + "ref": {"type": "string", "description": "Branch o tag (default: branch predefinito del repo)"}, + }, + }, + }, + { + "name": "search_code", + "description": "Cerca testo nel codice o nei file di un repository Gitea", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "repo": {"type": "string", "description": "Limita la ricerca a owner/name"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + }, + { + "name": "list_issues", + "description": "Elenca le issue di un repository Gitea", + "inputSchema": { + "type": "object", + "required": ["repo"], + "properties": { + "repo": {"type": "string"}, + "state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"}, + "page": {"type": "integer", "default": 1}, + "limit": {"type": "integer", "default": 20}, + }, + }, + }, + { + "name": "get_issue", + "description": "Recupera una issue Gitea per numero", + "inputSchema": { + "type": "object", + "required": ["repo", "number"], + "properties": { + "repo": {"type": "string"}, + "number": {"type": "integer"}, + }, + }, + }, + { + "name": "create_issue", + "description": "Crea una nuova issue su un repository Gitea", + "inputSchema": { + "type": "object", + "required": ["repo", "title"], + "properties": { + "repo": {"type": "string"}, + "title": {"type": "string"}, + "body": {"type": "string", "default": ""}, + "labels": {"type": "array", "items": {"type": "string"}}, + }, + }, + }, + { + "name": "create_gitea_repo", + "description": "Crea un nuovo repository Gitea sotto l'utente corrente (workspace personale)", + "inputSchema": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "description": "Nome repo (es. progetti)"}, + "private": {"type": "boolean", "default": True}, + "description": {"type": "string", "default": ""}, + "auto_init": { + "type": "boolean", + "default": True, + "description": "Crea README.md iniziale", + }, + }, + }, + }, + { + "name": "create_or_update_file", + "description": "Crea o aggiorna un file su Gitea (commit singolo via API, equivalente a push di un file)", + "inputSchema": { + "type": "object", + "required": ["repo", "path", "content", "message"], + "properties": { + "repo": {"type": "string", "description": "owner/name"}, + "path": {"type": "string", "description": "Percorso file nel repo"}, + "content": {"type": "string", "description": "Contenuto testo del file"}, + "message": {"type": "string", "description": "Messaggio di commit"}, + "branch": {"type": "string", "description": "Branch (default: branch principale del repo)"}, + "reindex": { + "type": "boolean", + "default": True, + "description": "Aggiorna subito il vector store RAG per questo file", + }, + }, + }, + }, + { + "name": "get_home_dashboard", + "description": "Dashboard Loogle Casa: meteo, alert, rete, notifiche", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_home_weather", + "description": "Meteo attuale e previsioni per casa (Loogle Casa)", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_network_overview", + "description": "Panoramica rete domestica: dispositivi online/offline, IP pubblico", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_network_failover_status", + "description": "Stato cluster failover LOOGLE (Pi, NAS, servizi)", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_ha_entity", + "description": "Legge lo stato di un'entità Home Assistant (read-only)", + "inputSchema": { + "type": "object", + "required": ["entity_id"], + "properties": { + "entity_id": { + "type": "string", + "description": "Es. switch.pompa_pozzo, sensor.temperatura_sala", + }, + }, + }, + }, + { + "name": "list_ha_entities", + "description": "Elenca entità Home Assistant, opzionalmente filtrate per dominio", + "inputSchema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Filtra per dominio: switch, sensor, light, climate, …", + }, + "limit": {"type": "integer", "default": 50}, + }, + }, + }, + { + "name": "search_ha_entities", + "description": "Cerca entità Home Assistant per nome o entity_id", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + }, + { + "name": "get_irrigation_status", + "description": "Stato irrigazione: zone, programma, pozzo, sensori, connessione HA", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_irrigation_zones", + "description": "Elenco zone irrigazione con stato valvole e portata", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_irrigation_history", + "description": "Storico irrigazioni recenti", + "inputSchema": { + "type": "object", + "properties": {"limit": {"type": "integer", "default": 30}}, + }, + }, + { + "name": "get_turni_status", + "description": "Stato servizio Turni-Live (versione, ambiente)", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_my_shifts", + "description": "Turni di lavoro dell'utente corrente (Turni-Live)", + "inputSchema": { + "type": "object", + "properties": { + "from_date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + "to_date": {"type": "string", "description": "ISO date YYYY-MM-DD"}, + "limit": {"type": "integer", "default": 50}, + }, + }, + }, + { + "name": "list_turni_doctors", + "description": "Elenco medici/operatori in Turni-Live", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "search_apps_knowledge", + "description": "Ricerca semantica su storico Irrigazione e Turni indicizzati", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "source": { + "type": "string", + "enum": ["irrigazione", "turni"], + "description": "Filtra per sorgente app", + }, + "limit": {"type": "integer", "default": 8}, + }, + }, + }, + { + "name": "list_apps_indexed", + "description": "Elenca record Irrigazione/Turni indicizzati nel vector store", + "inputSchema": { + "type": "object", + "properties": { + "source": {"type": "string", "enum": ["irrigazione", "turni"]}, + "limit": {"type": "integer", "default": 30}, + }, + }, + }, + { + "name": "reindex_apps", + "description": "Forza re-indicizzazione RAG da Irrigazione e/o Turni (admin)", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": ["irrigazione", "turni", "all"], + "default": "all", + }, + }, + }, + }, + ] + + +def _text_result(payload: Any) -> dict: + return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}]} + + +def call_tool(name: str, arguments: dict, claims: dict) -> dict: + username = claims["sub"] + is_admin = "admin" in (claims.get("scope") or "").split() + + if name == "ping": + audit.log_tool(username, name) + return _text_result({"pong": True, "service": "loogle-mcp"}) + + if name == "whoami": + audit.log_tool(username, name) + return _text_result({"username": username, "scope": claims.get("scope"), "is_admin": is_admin}) + + if name == "list_projects": + if not has_scope(claims, "context:read"): + raise PermissionError("Scope context:read richiesto") + projects = context_store.list_projects(username, arguments.get("include_archived", False)) + audit.log_tool(username, name) + return _text_result({"projects": projects}) + + if name == "create_project": + if not has_scope(claims, "context:write"): + raise PermissionError("Scope context:write richiesto") + gitea_repo = arguments.get("gitea_repo") + if gitea_repo and not has_scope(claims, "gitea:read"): + raise PermissionError("Scope gitea:read richiesto per collegare un repository") + try: + meta = context_store.create_project( + username, + arguments["title"], + arguments.get("tags"), + gitea_repo=gitea_repo, + seed_from_gitea=bool(arguments.get("seed_from_gitea")), + ) + except RuntimeError as exc: + raise PermissionError(str(exc)) from exc + except ValueError as exc: + raise ValueError(str(exc)) from exc + audit.log_tool(username, name, meta["id"]) + return _text_result(meta) + + if name == "link_project_repo": + if not has_scope(claims, "context:write"): + raise PermissionError("Scope context:write richiesto") + gitea_repo = arguments.get("gitea_repo") + if gitea_repo and not has_scope(claims, "gitea:read"): + raise PermissionError("Scope gitea:read richiesto per collegare un repository") + try: + meta = context_store.link_project_repo( + username, + arguments["project_id"], + gitea_repo=gitea_repo, + seed_from_gitea=bool(arguments.get("seed_from_gitea")), + ) + except RuntimeError as exc: + raise PermissionError(str(exc)) from exc + except ValueError as exc: + raise ValueError(str(exc)) from exc + audit.log_tool(username, name, arguments["project_id"]) + return _text_result(meta) + + if name == "get_project_context": + if not has_scope(claims, "context:read"): + raise PermissionError("Scope context:read richiesto") + include_gitea = arguments.get("include_gitea", True) + if include_gitea and not has_scope(claims, "gitea:read"): + include_gitea = False + data = context_store.get_project_context( + username, + arguments["project_id"], + arguments.get("session_limit", 5), + include_gitea=include_gitea, + ) + if include_gitea is False and arguments.get("include_gitea", True): + meta = data.get("meta") or {} + if meta.get("gitea_repo"): + data["gitea"] = { + "linked": True, + "repo": meta["gitea_repo"], + "available": False, + "error": "Scope gitea:read richiesto per arricchimento repository", + } + audit.log_tool(username, name, arguments["project_id"]) + return _text_result(data) + + if name == "save_context": + if not has_scope(claims, "context:write"): + raise PermissionError("Scope context:write richiesto") + meta = context_store.save_context( + username, + arguments["project_id"], + arguments["content"], + arguments.get("mode", "append"), + ) + try: + indexer.index_context_snippet( + username, arguments["project_id"], arguments["content"], meta["title"] + ) + except Exception: + pass + audit.log_tool(username, name, arguments["project_id"]) + return _text_result(meta) + + if name == "archive_project": + if not has_scope(claims, "context:write"): + raise PermissionError("Scope context:write richiesto") + meta = context_store.archive_project( + username, arguments["project_id"], arguments.get("archived", True) + ) + audit.log_tool(username, name, arguments["project_id"]) + return _text_result(meta) + + if name == "search_context": + if not has_scope(claims, "context:read"): + raise PermissionError("Scope context:read richiesto") + hits = indexer.search_context(username, arguments["query"], arguments.get("limit", 8)) + audit.log_tool(username, name, detail={"query": arguments["query"]}) + return _text_result({"results": hits}) + + if name == "search_knowledge": + if not has_scope(claims, "knowledge:read"): + raise PermissionError("Scope knowledge:read richiesto") + hits = indexer.search_knowledge( + username, arguments["query"], arguments.get("limit", 8), is_admin=is_admin + ) + audit.log_tool(username, name, detail={"query": arguments["query"]}) + return _text_result({"results": hits}) + + if name == "search_gitea_knowledge": + if not has_scope(claims, "knowledge:read"): + raise PermissionError("Scope knowledge:read richiesto") + if not gitea.is_configured(username): + raise PermissionError("Gitea non configurato per questo utente") + hits = indexer.search_gitea_knowledge( + username, arguments["query"], arguments.get("limit", 8), is_admin=is_admin + ) + audit.log_tool(username, name, detail={"query": arguments["query"]}) + return _text_result({"results": hits}) + + if name == "list_gitea_indexed_files": + if not has_scope(claims, "knowledge:read"): + raise PermissionError("Scope knowledge:read richiesto") + files = gitea_indexer.list_indexed_files( + arguments.get("limit", 30), + repo=arguments.get("repo"), + ) + audit.log_tool(username, name, detail={"repo": arguments.get("repo")}) + return _text_result({"files": files, "count": len(files)}) + + if name == "reindex_gitea_repo": + if not has_scope(claims, "gitea:read"): + raise PermissionError("Scope gitea:read richiesto") + if not gitea.is_configured(username): + raise PermissionError("Gitea non configurato per questo utente") + repo = arguments["repo"] + owner = repo.split("/", 1)[0].lower() + if not is_admin and owner != username: + raise PermissionError("Puoi re-indicizzare solo repository di cui sei owner") + try: + result = gitea_indexer.index_repo( + repo, + username=username, + force=True, + max_files=arguments.get("max_files"), + ) + except Exception as exc: + raise PermissionError(f"Re-indicizzazione Gitea fallita: {exc}") from exc + audit.log_tool(username, name, detail={"repo": repo}) + return _text_result(result) + + if name == "get_document": + if not has_scope(claims, "knowledge:read"): + raise PermissionError("Scope knowledge:read richiesto") + try: + text = paperless.download_document_text(int(arguments["doc_id"]), username=username) + except Exception as exc: + raise PermissionError(f"Documento non accessibile con il tuo account Paperless: {exc}") + audit.log_tool(username, name, str(arguments["doc_id"])) + return _text_result({"doc_id": arguments["doc_id"], "content": text}) + + if name == "list_recent_documents": + if not has_scope(claims, "knowledge:read"): + raise PermissionError("Scope knowledge:read richiesto") + docs = indexer.list_recent_documents(arguments.get("limit", 20)) + audit.log_tool(username, name) + return _text_result({"documents": docs}) + + if name == "reindex_document": + if not is_admin: + raise PermissionError("Scope admin richiesto") + result = indexer.index_document(int(arguments["doc_id"]), force=True) + audit.log_tool(username, name, str(arguments["doc_id"])) + return _text_result(result) + + if name in ( + "list_repos", "get_file", "search_code", "list_issues", "get_issue", "create_issue", + "create_gitea_repo", "create_or_update_file", + ): + if not gitea.is_configured(username): + raise PermissionError( + "Gitea non configurato per questo utente. " + "Aggiungi GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md" + ) + + if name == "list_repos": + if not has_scope(claims, "gitea:read"): + raise PermissionError("Scope gitea:read richiesto") + try: + result = gitea.list_repos( + username=username, + page=int(arguments.get("page", 1)), + limit=int(arguments.get("limit", 50)), + ) + except Exception as exc: + raise PermissionError(f"Gitea non accessibile: {exc}") from exc + audit.log_tool(username, name) + return _text_result(result) + + if name == "get_file": + if not has_scope(claims, "gitea:read"): + raise PermissionError("Scope gitea:read richiesto") + try: + result = gitea.get_file( + arguments["repo"], + arguments["path"], + ref=arguments.get("ref"), + username=username, + ) + except FileNotFoundError as exc: + raise ValueError(str(exc)) from exc + except Exception as exc: + raise PermissionError(f"File Gitea non accessibile: {exc}") from exc + audit.log_tool(username, name, detail={"repo": arguments["repo"], "path": arguments["path"]}) + return _text_result(result) + + if name == "search_code": + if not has_scope(claims, "gitea:read"): + raise PermissionError("Scope gitea:read richiesto") + try: + result = gitea.search_code( + arguments["query"], + repo=arguments.get("repo"), + limit=int(arguments.get("limit", 20)), + username=username, + ) + except Exception as exc: + raise PermissionError(f"Ricerca Gitea fallita: {exc}") from exc + audit.log_tool(username, name, detail={"query": arguments["query"], "repo": arguments.get("repo")}) + return _text_result(result) + + if name == "list_issues": + if not has_scope(claims, "gitea:read"): + raise PermissionError("Scope gitea:read richiesto") + try: + result = gitea.list_issues( + arguments["repo"], + state=arguments.get("state", "open"), + page=int(arguments.get("page", 1)), + limit=int(arguments.get("limit", 20)), + username=username, + ) + except Exception as exc: + raise PermissionError(f"Issue Gitea non accessibili: {exc}") from exc + audit.log_tool(username, name, detail={"repo": arguments["repo"]}) + return _text_result(result) + + if name == "get_issue": + if not has_scope(claims, "gitea:read"): + raise PermissionError("Scope gitea:read richiesto") + try: + result = gitea.get_issue( + arguments["repo"], + int(arguments["number"]), + username=username, + ) + except Exception as exc: + raise PermissionError(f"Issue Gitea non accessibile: {exc}") from exc + audit.log_tool(username, name, detail={"repo": arguments["repo"], "number": arguments["number"]}) + return _text_result(result) + + if name == "create_issue": + if not has_scope(claims, "gitea:write"): + raise PermissionError("Scope gitea:write richiesto") + try: + result = gitea.create_issue( + arguments["repo"], + arguments["title"], + body=arguments.get("body", ""), + labels=arguments.get("labels"), + username=username, + ) + except Exception as exc: + raise PermissionError(f"Creazione issue Gitea fallita: {exc}") from exc + audit.log_tool(username, name, detail={"repo": arguments["repo"], "title": arguments["title"]}) + return _text_result(result) + + if name == "create_gitea_repo": + if not has_scope(claims, "gitea:write"): + raise PermissionError("Scope gitea:write richiesto") + try: + result = gitea.create_repo( + arguments["name"], + username=username, + private=bool(arguments.get("private", True)), + description=arguments.get("description", ""), + auto_init=bool(arguments.get("auto_init", True)), + ) + except Exception as exc: + raise PermissionError(f"Creazione repo Gitea fallita: {exc}") from exc + audit.log_tool(username, name, detail={"name": arguments["name"]}) + return _text_result(result) + + if name == "create_or_update_file": + if not has_scope(claims, "gitea:write"): + raise PermissionError("Scope gitea:write richiesto") + repo = arguments["repo"] + try: + gitea.assert_repo_owner(username, repo, is_admin=is_admin) + result = gitea.create_or_update_file( + repo, + arguments["path"], + arguments["content"], + arguments["message"], + branch=arguments.get("branch"), + username=username, + ) + if arguments.get("reindex", True): + try: + owner, repo_name = gitea.parse_repo(repo) + meta = gitea._request( + "GET", f"/repos/{owner}/{repo_name}", username=username + ) + private = bool(meta.get("private")) + idx = gitea_indexer.index_file( + repo, + arguments["path"], + username=username, + private=private, + force=True, + ) + result["reindex"] = idx + except Exception: + result["reindex"] = {"skipped": True} + except PermissionError: + raise + except Exception as exc: + raise PermissionError(f"Scrittura file Gitea fallita: {exc}") from exc + audit.log_tool( + username, + name, + detail={"repo": repo, "path": arguments["path"], "action": result.get("action")}, + ) + return _text_result(result) + + if name in ( + "get_home_dashboard", "get_home_weather", "get_network_overview", + "get_network_failover_status", + ): + if not has_scope(claims, "home:read"): + raise PermissionError("Scope home:read richiesto") + if not casa.is_configured(username): + raise PermissionError( + "Loogle Casa non configurato. Imposta LOOGLE_CASA_PASSWORD_{USER} in .env" + ) + try: + if name == "get_home_dashboard": + payload = casa.get_dashboard(username) + elif name == "get_home_weather": + payload = casa.get_weather_home(username) + elif name == "get_network_overview": + payload = casa.get_network_overview(username) + else: + payload = casa.get_network_failover_status(username) + except Exception as exc: + raise PermissionError(f"Loogle Casa non accessibile: {exc}") from exc + audit.log_tool(username, name) + return _text_result(payload) + + if name in ("get_ha_entity", "list_ha_entities", "search_ha_entities"): + if not has_scope(claims, "home:read"): + raise PermissionError("Scope home:read richiesto") + if not homeassistant.is_configured(): + raise PermissionError("Home Assistant non configurato — imposta HA_TOKEN in .env") + try: + if name == "get_ha_entity": + payload = homeassistant.get_entity(arguments["entity_id"]) + elif name == "list_ha_entities": + payload = { + "entities": homeassistant.list_entities( + domain=arguments.get("domain"), + limit=int(arguments.get("limit", 50)), + ), + } + else: + payload = { + "results": homeassistant.search_entities( + arguments["query"], + limit=int(arguments.get("limit", 20)), + ), + } + except Exception as exc: + raise PermissionError(f"Home Assistant non accessibile: {exc}") from exc + audit.log_tool(username, name, detail=arguments.get("entity_id") or arguments.get("query")) + return _text_result(payload) + + if name in ("get_irrigation_status", "get_irrigation_zones", "get_irrigation_history"): + if not has_scope(claims, "irrigation:read"): + raise PermissionError("Scope irrigation:read richiesto") + if not irrigazione.is_configured(username): + raise PermissionError( + "Irrigazione non configurata. Imposta IRRIGAZIONE_PASSWORD_{USER} in .env" + ) + try: + if name == "get_irrigation_status": + payload = irrigazione.get_status(username) + elif name == "get_irrigation_zones": + payload = irrigazione.get_zones(username) + else: + payload = irrigazione.get_history(username, int(arguments.get("limit", 30))) + except Exception as exc: + raise PermissionError(f"Irrigazione non accessibile: {exc}") from exc + audit.log_tool(username, name) + return _text_result(payload) + + if name in ("get_turni_status", "get_my_shifts", "list_turni_doctors"): + if name != "get_turni_status" and not has_scope(claims, "turni:read"): + raise PermissionError("Scope turni:read richiesto") + try: + if name == "get_turni_status": + payload = turni.get_status() + elif name == "list_turni_doctors": + if not turni.is_configured(username): + raise PermissionError("Turni non configurato per questo utente") + payload = {"doctors": turni.list_doctors(username)} + else: + if not turni.is_configured(username): + raise PermissionError("Turni non configurato per questo utente") + payload = turni.get_my_shifts( + username, + from_date=arguments.get("from_date"), + to_date=arguments.get("to_date"), + limit=int(arguments.get("limit", 50)), + ) + except PermissionError: + raise + except Exception as exc: + raise PermissionError(f"Turni non accessibile: {exc}") from exc + audit.log_tool(username, name) + return _text_result(payload) + + if name == "search_apps_knowledge": + if not has_scope(claims, "knowledge:read"): + raise PermissionError("Scope knowledge:read richiesto") + hits = apps_indexer.search_apps_knowledge( + arguments["query"], + limit=int(arguments.get("limit", 8)), + source=arguments.get("source"), + ) + audit.log_tool(username, name, detail={"query": arguments["query"]}) + return _text_result({"results": hits}) + + if name == "list_apps_indexed": + if not has_scope(claims, "knowledge:read"): + raise PermissionError("Scope knowledge:read richiesto") + records = apps_indexer.list_indexed_records( + source=arguments.get("source"), + limit=int(arguments.get("limit", 30)), + ) + audit.log_tool(username, name) + return _text_result({"records": records, "count": len(records)}) + + if name == "reindex_apps": + if not is_admin: + raise PermissionError("Scope admin richiesto") + src = arguments.get("source", "all") + if src == "irrigazione": + result = apps_indexer.index_irrigazione() + elif src == "turni": + result = apps_indexer.index_turni() + else: + result = apps_indexer.index_all() + audit.log_tool(username, name, detail={"source": src}) + return _text_result(result) + + raise ValueError(f"Tool sconosciuto: {name}") + + +def list_resources(claims: dict) -> list: + username = claims["sub"] + return context_store.list_resources(username) + + +def read_resource(uri: str, claims: dict) -> dict: + username = claims["sub"] + return context_store.read_resource(username, uri) diff --git a/services/loogle-mcp/app/oauth.py b/services/loogle-mcp/app/oauth.py new file mode 100644 index 0000000..439e00e --- /dev/null +++ b/services/loogle-mcp/app/oauth.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- +"""OAuth 2.1 Authorization Code + PKCE.""" + +import base64 +import datetime +import hashlib +import json +import os +import secrets +from typing import Optional +from urllib.parse import urlencode, urlparse + +from fastapi import HTTPException + +from . import auth +from .db import get_conn +from .jwt_utils import ( + ACCESS_TOKEN_HOURS, + base_url, + create_access_token, + create_refresh_token, + decode_access_token, + revoke_refresh_token, + scopes_for_user, + consume_refresh_token, +) + + +def _pkce_valid(code_verifier: str, challenge: str, method: str) -> bool: + if method != "S256": + return False + digest = hashlib.sha256(code_verifier.encode()).digest() + computed = base64.urlsafe_b64encode(digest).decode().rstrip("=") + return computed == challenge + + +TRUSTED_REDIRECT_URIS = frozenset({ + "https://claude.ai/api/mcp/auth_callback", + "https://chatgpt.com/connector_platform_oauth_redirect", + "https://chat.openai.com/connector_platform_oauth_redirect", + # Cursor IDE / Agents (docs.cursor.com/mcp) + "https://www.cursor.com/agents/mcp/oauth/callback", + "http://localhost:8787/callback", + # Legacy Cursor desktop + "cursor://anysphere.cursor-mcp/oauth/callback", +}) + + +def ensure_default_client() -> None: + clients = [ + ( + os.environ.get("MCP_OAUTH_CLIENT_ID", "loogle-mcp-public"), + "Loogle MCP Public", + [ + "https://chatgpt.com/connector_platform_oauth_redirect", + "https://chat.openai.com/connector_platform_oauth_redirect", + "https://claude.ai/api/mcp/auth_callback", + "https://www.cursor.com/agents/mcp/oauth/callback", + "http://localhost:8787/callback", + "cursor://anysphere.cursor-mcp/oauth/callback", + "http://127.0.0.1:*/callback", + "http://localhost:*/callback", + ], + ), + ( + "cursor", + "Cursor IDE", + [ + "https://www.cursor.com/agents/mcp/oauth/callback", + "http://localhost:8787/callback", + "cursor://anysphere.cursor-mcp/oauth/callback", + ], + ), + ( + "claude-desktop", + "Claude Desktop/App", + ["https://claude.ai/api/mcp/auth_callback"], + ), + ( + "claude-ai", + "Claude.ai", + ["https://claude.ai/api/mcp/auth_callback"], + ), + ] + conn = get_conn() + for client_id, client_name, redirect_uris in clients: + row = conn.execute( + "SELECT client_id FROM oauth_clients WHERE client_id=?", (client_id,) + ).fetchone() + if row: + conn.execute( + "UPDATE oauth_clients SET client_name=?, redirect_uris=? WHERE client_id=?", + (client_name, json.dumps(redirect_uris), client_id), + ) + else: + conn.execute( + "INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)", + (client_id, client_name, json.dumps(redirect_uris)), + ) + conn.commit() + + +def _is_trusted_redirect_uri(redirect_uri: str) -> bool: + if redirect_uri in TRUSTED_REDIRECT_URIS: + return True + parsed = urlparse(redirect_uri) + if parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost"): + if (parsed.path or "").endswith("/callback"): + return True + return False + + +def register_client(client_name: str, redirect_uris: list[str]) -> dict: + client_id = secrets.token_urlsafe(16) + for uri in redirect_uris: + if not _is_trusted_redirect_uri(uri): + raise HTTPException( + status_code=400, + detail=f"Redirect URI non consentito: {uri}", + ) + get_conn().execute( + "INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)", + (client_id, client_name, json.dumps(redirect_uris)), + ) + get_conn().commit() + return {"client_id": client_id, "client_name": client_name, "redirect_uris": redirect_uris} + + +def _client_redirect_uris(client_id: str) -> list[str]: + row = get_conn().execute( + "SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,) + ).fetchone() + if not row: + return [] + return json.loads(row["redirect_uris"]) + + +def _ensure_client_for_redirect(client_id: str, redirect_uri: str) -> None: + """Registra client OAuth al volo (Claude usa spesso client_id = username).""" + conn = get_conn() + row = conn.execute( + "SELECT redirect_uris FROM oauth_clients WHERE client_id=?", (client_id,) + ).fetchone() + if row: + uris = set(json.loads(row["redirect_uris"])) + if redirect_uri not in uris: + uris.add(redirect_uri) + conn.execute( + "UPDATE oauth_clients SET redirect_uris=? WHERE client_id=?", + (json.dumps(sorted(uris)), client_id), + ) + conn.commit() + return + conn.execute( + "INSERT INTO oauth_clients(client_id,client_name,redirect_uris) VALUES (?,?,?)", + (client_id, f"MCP client {client_id}", json.dumps([redirect_uri])), + ) + conn.commit() + + +def _redirect_allowed(client_id: str, redirect_uri: str) -> bool: + if _is_trusted_redirect_uri(redirect_uri): + _ensure_client_for_redirect(client_id, redirect_uri) + return True + allowed = _client_redirect_uris(client_id) + if redirect_uri in allowed: + return True + parsed = urlparse(redirect_uri) + for pattern in allowed: + if "*" in pattern: + pp = urlparse(pattern.replace("*", "placeholder")) + if parsed.scheme == pp.scheme and parsed.netloc.endswith(pp.netloc.split("placeholder")[-1]): + return True + return False + + +def create_auth_code( + client_id: str, + user_id: int, + redirect_uri: str, + scope: str, + code_challenge: Optional[str], + code_challenge_method: Optional[str], +) -> str: + code = secrets.token_urlsafe(32) + expires = ( + datetime.datetime.utcnow() + datetime.timedelta(minutes=10) + ).strftime("%Y-%m-%d %H:%M:%S") + get_conn().execute( + "INSERT INTO oauth_codes(code,client_id,user_id,redirect_uri,scope,code_challenge,code_challenge_method,expires_at)" + " VALUES (?,?,?,?,?,?,?,?)", + (code, client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method, expires), + ) + get_conn().commit() + return code + + +def exchange_code( + code: str, + client_id: str, + redirect_uri: str, + code_verifier: Optional[str], +) -> dict: + row = get_conn().execute( + "SELECT * FROM oauth_codes WHERE code=? AND used=0 AND expires_at > datetime('now')", + (code,), + ).fetchone() + if not row: + raise HTTPException(400, "Codice non valido o scaduto") + row = dict(row) + if row["client_id"] != client_id or row["redirect_uri"] != redirect_uri: + raise HTTPException(400, "Client o redirect URI non validi") + if row.get("code_challenge"): + if not code_verifier or not _pkce_valid(code_verifier, row["code_challenge"], row.get("code_challenge_method") or "S256"): + raise HTTPException(400, "PKCE verification failed") + user = auth.get_user_by_id(row["user_id"]) + if not user: + raise HTTPException(400, "Utente non trovato") + get_conn().execute("UPDATE oauth_codes SET used=1 WHERE code=?", (code,)) + get_conn().commit() + scope = scopes_for_user(user, row["scope"]) + access_token, _ = create_access_token(user, scope, client_id) + refresh = create_refresh_token(user["id"], scope, client_id) + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": ACCESS_TOKEN_HOURS * 3600, + "refresh_token": refresh, + "scope": scope, + } + + +def refresh_access_token(refresh_token: str, client_id: str) -> dict: + row = consume_refresh_token(refresh_token) + if not row or row["client_id"] != client_id: + raise HTTPException(400, "Refresh token non valido") + user = auth.get_user_by_id(row["user_id"]) + if not user: + raise HTTPException(400, "Utente non trovato") + revoke_refresh_token(refresh_token) + scope = row["scope"] + access_token, _ = create_access_token(user, scope, client_id) + refresh = create_refresh_token(user["id"], scope, client_id) + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": ACCESS_TOKEN_HOURS * 3600, + "refresh_token": refresh, + "scope": scope, + } + + +def authorization_server_metadata() -> dict: + base = base_url() + return { + "issuer": base, + "authorization_endpoint": f"{base}/authorize", + "token_endpoint": f"{base}/token", + "registration_endpoint": f"{base}/register", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none", "client_secret_post"], + "scopes_supported": [ + "context:read", + "context:write", + "knowledge:read", + "knowledge:write", + "gitea:read", + "gitea:write", + "home:read", + "irrigation:read", + "turni:read", + "admin", + ], + } + + +def protected_resource_metadata() -> dict: + base = base_url() + return { + "resource": f"{base}/mcp", + "authorization_servers": [base], + "scopes_supported": [ + "context:read", + "context:write", + "knowledge:read", + "knowledge:write", + "gitea:read", + "gitea:write", + "home:read", + "irrigation:read", + "turni:read", + ], + "bearer_methods_supported": ["header"], + } + + +def build_authorize_redirect( + client_id: str, + redirect_uri: str, + scope: str, + state: str, + code_challenge: Optional[str], + code_challenge_method: Optional[str], + user_id: int, +) -> str: + if not _redirect_allowed(client_id, redirect_uri): + raise HTTPException(400, "Redirect URI non autorizzato") + code = create_auth_code( + client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method + ) + params = {"code": code, "state": state} + sep = "&" if "?" in redirect_uri else "?" + return f"{redirect_uri}{sep}{urlencode(params)}" + + +def bearer_claims_from_header(authorization: str) -> Optional[dict]: + if not authorization.lower().startswith("bearer "): + return None + token = authorization[7:].strip() + return decode_access_token(token) diff --git a/services/loogle-mcp/app/static/dashboard.html b/services/loogle-mcp/app/static/dashboard.html new file mode 100644 index 0000000..ba6a885 --- /dev/null +++ b/services/loogle-mcp/app/static/dashboard.html @@ -0,0 +1,129 @@ + + + + + + Loogle MCP Dashboard + + + +

Loogle MCP Hub

+

Archivio contesto e knowledge base locale per AI famiglia

+ +
+

Accedi

+ + + + + + +
+ + + + + + diff --git a/services/loogle-mcp/docker-compose.yml b/services/loogle-mcp/docker-compose.yml new file mode 100644 index 0000000..72c18eb --- /dev/null +++ b/services/loogle-mcp/docker-compose.yml @@ -0,0 +1,63 @@ +services: + loogle-mcp: + build: + context: . + dockerfile: Dockerfile + image: loogle-mcp:latest + container_name: loogle-mcp + restart: always + ports: + - "8700:8700" + env_file: + - .env + environment: + - TZ=Europe/Rome + - MCP_DB=/data/loogle_mcp.db + - MCP_CONTEXT_ROOT=/data/context + - MCP_AUDIT_LOG=/data/audit.log + - MCP_VECTOR_FALLBACK=/data/vector_fallback.db + volumes: + - ./data:/data + - /mnt/ha-apps/mcp/context:/data/context + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8700/health"] + interval: 30s + timeout: 5s + retries: 3 + + loogle-mcp-indexer: + build: + context: . + dockerfile: Dockerfile + image: loogle-mcp:latest + container_name: loogle-mcp-indexer + restart: always + env_file: + - .env + environment: + - TZ=Europe/Rome + - MCP_DB=/data/loogle_mcp.db + - MCP_CONTEXT_ROOT=/data/context + - MCP_VECTOR_FALLBACK=/data/vector_fallback.db + - DS920_SSH_HOST=192.168.128.100 + - DS920_SSH_USER=daniely + - DS920_SSH_KEY=/run/secrets/ds920_ssh_key + - DS920_THERMAL_URL=http://192.168.128.100:9191/thermal + command: ["python", "-m", "worker.indexer_main"] + volumes: + - ./data:/data + - /mnt/ha-apps/mcp/context:/data/context + - /home/daniely/.ssh/id_ed25519:/run/secrets/ds920_ssh_key:ro + depends_on: + - loogle-mcp + + # Qdrant locale: solo su x86/DS920 — su Pi5 ARM 16K page size non supportato + qdrant-local: + profiles: ["qdrant-local"] + image: qdrant/qdrant:v1.13.2 + container_name: loogle_mcp_qdrant + restart: always + ports: + - "6333:6333" + volumes: + - /mnt/ha-apps/mcp/qdrant:/qdrant/storage diff --git a/services/loogle-mcp/docs/APPS-INTEGRATION.md b/services/loogle-mcp/docs/APPS-INTEGRATION.md new file mode 100644 index 0000000..c4e7c2b --- /dev/null +++ b/services/loogle-mcp/docs/APPS-INTEGRATION.md @@ -0,0 +1,91 @@ +# Integrazione app homelab — P5/P6/P7 + +Tool live e RAG su **Loogle Casa**, **Home Assistant**, **Irrigazione** e **Turni**. + +--- + +## P5 — Casa + Home Assistant + +| Tool | Scope | Sorgente | +|------|-------|----------| +| `get_home_dashboard` | home:read | Loogle Casa | +| `get_home_weather` | home:read | Loogle Casa | +| `get_network_overview` | home:read | Loogle Casa | +| `get_network_failover_status` | home:read | Loogle Casa | +| `get_ha_entity` | home:read | Home Assistant | +| `list_ha_entities` | home:read | Home Assistant | +| `search_ha_entities` | home:read | Home Assistant | + +### Variabili `.env` + +```bash +LOOGLE_CASA_URL=https://casa.loogle.it +LOOGLE_CASA_API_URL=http://192.168.128.81:5602 # opzionale, LAN +LOOGLE_CASA_PASSWORD_DANIELE=... # daniele MCP → admin Casa + +HA_URL=https://ha.loogle.it +HA_API_URL=http://192.168.128.81:8123 +HA_TOKEN= +``` + +**Mapping utenti MCP → app:** +- `daniele` → Casa `admin`, Irrigazione `admin`, Turni `daniely` + +--- + +## P6 — Irrigazione + Turni + +| Tool | Scope | Sorgente | +|------|-------|----------| +| `get_irrigation_status` | irrigation:read | irri.loogle.it | +| `get_irrigation_zones` | irrigation:read | irri.loogle.it | +| `get_irrigation_history` | irrigation:read | irri.loogle.it | +| `get_turni_status` | — (pubblico) | turni.loogle.it | +| `get_my_shifts` | turni:read | turni.loogle.it | +| `list_turni_doctors` | turni:read | turni.loogle.it | + +```bash +IRRIGAZIONE_URL=https://irri.loogle.it +IRRIGAZIONE_API_URL=http://192.168.128.81:5601 +IRRIGAZIONE_PASSWORD_DANIELE=... + +TURNI_URL=https://turni.loogle.it +TURNI_PASSWORD_DANIELE=... # utente Turni: daniely +# oppure TURNI_JWT_DANIELE=... +``` + +--- + +## P7 — RAG storico app + +Il worker `loogle-mcp-indexer` indicizza periodicamente: +- snapshot stato/zone Irrigazione +- storico irrigazioni ed eventi +- lavori manutenzione giardino +- turni e anagrafica medici Turni + +| Collection Qdrant | Contenuto | +|-------------------|-----------| +| `apps_shared_family` | Irrigazione + Turni (famiglia) | + +| Tool | Scope | Funzione | +|------|-------|----------| +| `search_apps_knowledge` | knowledge:read | RAG solo app | +| `search_knowledge` | knowledge:read | Include anche app | +| `list_apps_indexed` | knowledge:read | Stato indicizzazione | +| `reindex_apps` | admin | Forza sync | + +```bash +APPS_INDEX_ENABLED=yes +APPS_INDEX_USER=daniele +``` + +--- + +## Test + +```bash +docker exec loogle-mcp python3 /srv/scripts/test_p5_home.py +docker exec loogle-mcp python3 /srv/scripts/test_p6_apps.py +docker exec loogle-mcp python3 /srv/scripts/test_p7_apps_rag.py +``` diff --git a/services/loogle-mcp/docs/ARCHITETTURA.md b/services/loogle-mcp/docs/ARCHITETTURA.md new file mode 100644 index 0000000..353c10b --- /dev/null +++ b/services/loogle-mcp/docs/ARCHITETTURA.md @@ -0,0 +1,297 @@ +# Loogle MCP Hub — Architettura e parametri + +Documento di riferimento per la piattaforma MCP domestica LOOGLE.IT. +Ultimo aggiornamento: 2026-08-22 + +--- + +## Scopo + +**Loogle MCP Hub** espone agli assistenti AI (Claude, ChatGPT, Gemini, Cursor) due capacità principali: + +1. **Archivio contesto** — memoria persistente per progetti e task, isolata per utente +2. **Knowledge base RAG** — ricerca semantica su documenti Paperless e sui contesti salvati + +Tutti i dati restano in LAN/NAS; l’accesso esterno avviene solo via HTTPS + OAuth. + +--- + +## Architettura logica + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Client AI (ChatGPT / Claude / Gemini / Cursor) │ +└───────────────────────────┬─────────────────────────────────┘ + │ HTTPS + OAuth Bearer + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ NPM VIP 192.168.128.85 → mcp.loogle.it:443 │ +└───────────────────────────┬─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Pi-1 :8700 loogle-mcp (FastAPI) │ +│ ├── /mcp JSON-RPC MCP │ +│ ├── /oauth/* OAuth 2.1 PKCE │ +│ └── /dashboard UI famiglia │ +│ Pi-1 :8700 loogle-mcp-indexer (worker Paperless→vector) │ +└───────┬─────────────────────────────┬───────────────────────┘ + │ │ + ▼ ▼ +┌───────────────┐ ┌─────────────────────┐ +│ SQLite │ │ Vector store │ +│ /data/*.db │ │ Qdrant DS920 :6333 │ +│ utenti OAuth │ │ o fallback SQLite │ +└───────────────┘ └─────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ NFS /mnt/ha-apps/mcp/context/{utente}/projects/... │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Paperless docs.loogle.it (sorgente documenti + OCR) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Componenti e percorsi + +| Componente | Host | Porta | Percorso / container | +|------------|------|-------|----------------------| +| MCP Gateway | Pi-1 | 8700 | `loogle-mcp` | +| Indexer | Pi-1 | — | `loogle-mcp-indexer` | +| Qdrant (primario) | DS920 | 6333 | opzionale, compose extrema | +| Vector fallback | Pi-1 | — | `/data/vector_fallback.db` | +| Context NFS | NAS | — | `/mnt/ha-apps/mcp/context/` | +| DB SQLite | Pi-1 | — | `/home/daniely/docker/loogle-mcp/data/loogle_mcp.db` | +| Compose locale | Pi-1 | — | `/home/daniely/docker/loogle-mcp/` | +| Compose failover | Pi-1/Pi-2 | — | `/home/daniely/rete/compose/failover/loogle-mcp-compose.yml` | +| Runbook | — | — | `/home/daniely/rete/ha/RUNBOOK-mcp.md` | + +--- + +## URL e endpoint + +| Servizio | URL | +|----------|-----| +| MCP (client AI) | `https://mcp.loogle.it/mcp` | +| Dashboard web | `https://mcp.loogle.it/dashboard` | +| Health | `https://mcp.loogle.it/health` | +| OAuth authorize | `https://mcp.loogle.it/oauth/authorize` | +| OAuth token | `https://mcp.loogle.it/oauth/token` | +| Metadata OAuth | `https://mcp.loogle.it/.well-known/oauth-authorization-server` | +| Metadata risorsa MCP | `https://mcp.loogle.it/.well-known/oauth-protected-resource` | + +--- + +## DNS + +| Record | Tipo | Valore | Dove configurato | +|--------|------|--------|------------------| +| `mcp.loogle.it` | A | `192.168.128.85` (VIP NPM) | Pi-hole `/etc/pihole/pihole.toml` → hosts | +| Risoluzione LAN | — | Pi-hole → Unbound → split-horizon | Come `docs.loogle.it`, `pass.loogle.it` | + +**Script setup:** `/home/daniely/rete/scripts/setup-mcp-dns-npm.sh` + +**Verifica:** +```bash +dig +short mcp.loogle.it @127.0.0.1 # → 192.168.128.85 +curl -sf https://mcp.loogle.it/health +``` + +--- + +## NPM (reverse proxy) + +| Parametro | Valore | +|-----------|--------| +| Proxy host ID | 30 | +| Dominio | `mcp.loogle.it` | +| Upstream normale | `192.168.128.80:8700` | +| Certificato TLS | `npm-65` (Let's Encrypt) | +| Failover route | `rete/ha/npm-routes.conf` riga `30\|mcp.loogle.it\|8700\|...` | +| Websocket | ON | +| Force SSL | ON | + +**Failover:** `npm-failover-route.sh` aggiorna upstream su Pi-2 / DS920 in base allo scenario HA. + +--- + +## Variabili d'ambiente (`.env`) + +File: `/home/daniely/docker/loogle-mcp/.env` + +| Variabile | Descrizione | Esempio | +|-----------|-------------|---------| +| `MCP_BASE_URL` | URL pubblico (issuer JWT) | `https://mcp.loogle.it` | +| `MCP_JWT_SECRET` | Segreto firma token OAuth | *(stringa lunga casuale)* | +| `MCP_OAUTH_CLIENT_ID` | Client OAuth pre-registrato | `loogle-mcp-public` | +| `PAPERLESS_URL` | API documenti | `https://docs.loogle.it` | +| `PAPERLESS_API_TOKEN` | Token API admin (fallback) | vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) | +| `PAPERLESS_API_TOKEN_{USER}` | Token API per utente | `DANIELE`, `LUCIA`, `DAVIDE`, `LUCA` | +| `QDRANT_URL` | Vector DB remoto | `http://192.168.128.100:6333` | +| `OLLAMA_URL` | Embedding locale | `http://192.168.128.100:11434` | +| `OLLAMA_EMBED_MODEL` | Modello embedding | `nomic-embed-text` | +| `OPENAI_API_KEY` | Fallback embedding cloud | *(opzionale)* | +| `INDEXER_INTERVAL_MINUTES` | Intervallo sync Paperless | `30` | +| `THERMAL_TEMP_HARD_C` | Pausa embedding se CPU DS920 ≥ °C | `70` | +| `THERMAL_TEMP_SOFT_C` | Rallenta embedding sopra °C | `58` | +| `THERMAL_CPU_TARGET_PCT` | Cap load1 (nproc × %) | `40` | +| `OLLAMA_NUM_THREAD` | Thread CPU per richiesta embed | `1` | +| `OLLAMA_EMBED_DELAY_S` | Pausa tra embed (duty-cycle lento) | `10` | +| `DS920_THERMAL_URL` | Probe temp/load DS920 | `http://192.168.128.100:9191/thermal` | + +--- + +## Utenti e isolamento + +| Persona | Username | Ruolo | Password iniziale | +|---------|----------|-------|-------------------| +| Daniele | `daniele` | admin | `daniele` *(cambiarla)* | +| Lucia | `lucia` | user | `lucia` | +| Davide | `davide` | user | `davide` | +| Luca | `luca` | user | `luca` | + +- Ogni utente vede **solo** i propri progetti in `/mnt/ha-apps/mcp/context/{username}/` +- I token OAuth JWT contengono `sub` = username; ogni tool filtra per utente +- L’admin può `reindex_document`, vedere audit completo, revocare refresh token + +**Scope OAuth:** `context:read`, `context:write`, `knowledge:read`, `knowledge:write`, `gitea:read`, `gitea:write`, `home:read`, `irrigation:read`, `turni:read`, `admin` (solo daniele) + +--- + +## Tool MCP esposti + +| Tool | Scope minimo | Funzione | +|------|--------------|----------| +| `ping` | — | Test connettività | +| `whoami` | — | Utente e scope attivi | +| `list_projects` | context:read | Elenco progetti | +| `create_project` | context:write | Nuovo progetto | +| `get_project_context` | context:read | Legge memoria progetto | +| `save_context` | context:write | Salva/appende memoria | +| `archive_project` | context:write | Archivia progetto | +| `search_context` | context:read | Ricerca semantica contesti | +| `search_knowledge` | knowledge:read | RAG Paperless + contesti + Gitea + app | +| `search_gitea_knowledge` | knowledge:read | RAG solo Gitea | +| `search_apps_knowledge` | knowledge:read | RAG Irrigazione + Turni | +| `list_apps_indexed` | knowledge:read | Stato indicizzazione app | +| `reindex_apps` | admin | Re-sync RAG app | +| `get_document` | knowledge:read | Testo documento Paperless | +| `list_recent_documents` | knowledge:read | Ultimi doc indicizzati | +| `reindex_document` | admin | Re-indicizza documento | +| `get_home_dashboard` | home:read | Dashboard Loogle Casa | +| `get_home_weather` | home:read | Meteo casa | +| `get_network_overview` | home:read | Panoramica rete LAN | +| `get_network_failover_status` | home:read | Stato failover cluster | +| `get_ha_entity` | home:read | Entità Home Assistant | +| `list_ha_entities` | home:read | Elenco entità HA | +| `search_ha_entities` | home:read | Cerca entità HA | +| `get_irrigation_status` | irrigation:read | Stato irrigazione | +| `get_irrigation_zones` | irrigation:read | Zone irrigazione | +| `get_irrigation_history` | irrigation:read | Storico irrigazioni | +| `get_turni_status` | — | Stato servizio Turni | +| `get_my_shifts` | turni:read | Turni utente corrente | +| `list_turni_doctors` | turni:read | Medici Turni-Live | + +Vedi anche tool Gitea in `docs/GITEA-TOKEN.md` e app homelab in `docs/APPS-INTEGRATION.md`. + +--- + +## Storage contesto (per progetto) + +``` +/mnt/ha-apps/mcp/context/{username}/projects/{project-id}/ + meta.json # titolo, tag, date, stato, gitea_repo (opz.) + context.md # memoria persistente + sessions/ # snapshot conversazioni + artifacts/ # file generati dagli agenti +``` + +--- + +## Vector store / RAG + +| Collection Qdrant | Contenuto | +|-------------------|-----------| +| `kb_shared_family` | Documenti famiglia (tag non personali) | +| `kb_personal_{user}` | Documenti personali | +| `ctx_{user}` | Embedding dei contesti agente | +| `gitea_shared_family` | File testo da repo Gitea non privati | +| `gitea_personal_{user}` | File testo da repo Gitea privati dell'owner | +| `apps_shared_family` | Export Irrigazione + Turni (P7) | + +**Nota Pi 5:** Qdrant locale su ARM (page size 16K) non è supportato. Default: Qdrant su DS920 o fallback SQLite in `/data/vector_fallback.db`. + +**Pipeline indexer:** ogni 30 min: +1. Paperless → chunk → embedding (Ollama DS920) → upsert `kb_*` +2. Gitea (repo configurati in `GITEA_INDEX_REPOS_*`) → file `.md`/`.py`/`.sh` → chunk → upsert `gitea_*` +3. Irrigazione + Turni → snapshot/storico → upsert `apps_shared_family` (vedi `docs/APPS-INTEGRATION.md`) + +--- + +## Sicurezza + +- TLS terminato su NPM (Let's Encrypt) +- OAuth 2.1 Authorization Code + PKCE +- JWT access token 1h + refresh token 30 giorni (revocabili) +- Audit log: `{timestamp, user, tool, resource_id}` in SQLite +- CrowdSec attivo su NPM +- Nessun accesso cross-user ai dati + +--- + +## Operazioni comuni + +```bash +# Stato servizi +cd /home/daniely/docker/loogle-mcp && docker compose ps + +# Log +docker logs -f loogle-mcp +docker logs -f loogle-mcp-indexer + +# Redeploy +./scripts/deploy.sh + +# DNS + NPM (se reinstall) +sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh + +# Failover route +/home/daniely/rete/scripts/npm-failover-route.sh normal + +# Backup dati MCP +/home/daniely/rete/infra-monitor/mcp-restic-backup.sh +``` + +--- + +## Failover (tier-b) + +| Scenario | Comportamento | +|----------|---------------| +| Pi-1 down, Pi-2 up | Stack MCP su Pi-2, NPM upstream → `.81:8700` | +| Extrema DS920 | NPM3 + upstream DS920 | +| Monitoraggio | Probe `mcp_ok` in `failover-status.sh` | + +--- + +## Da completare (post-install) + +1. **`PAPERLESS_API_TOKEN_*`** in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) +2. **Ollama su DS920** con `nomic-embed-text` per embedding locali +3. **Qdrant su DS920** (opzionale): `rete/compose/extrema-ds920/loogle-mcp-compose.yml` +4. **Cambio password** per tutti gli utenti dal dashboard +5. **`MCP_JWT_SECRET`** — impostare valore robusto in produzione + +--- + +## Riferimenti + +- [Guida utenti famiglia](docs/GUIDA-UTENTI.md) +- [Onboarding tecnico client AI](docs/ONBOARDING.md) +- [Runbook operativo](/home/daniely/rete/ha/RUNBOOK-mcp.md) +- [README progetto](README.md) diff --git a/services/loogle-mcp/docs/GITEA-TOKEN.md b/services/loogle-mcp/docs/GITEA-TOKEN.md new file mode 100644 index 0000000..6b1f24e --- /dev/null +++ b/services/loogle-mcp/docs/GITEA-TOKEN.md @@ -0,0 +1,209 @@ +# Token Gitea per Loogle MCP + +## Cosa usare: Personal Access Token (PAT) + +Gitea espone l'API REST su `https://git.loogle.it/api/v1/` con header: + +``` +Authorization: token +``` + +Ogni utente Gitea ha i propri repository e permessi. L'hub MCP usa un token **per utente famiglia**, come Paperless. + +--- + +## Dove generarlo + +Per ogni utente che deve usare i tool Gitea da Claude/Cursor (daniele, lucia, …): + +1. Accedi a https://git.loogle.it con quell'account +2. **Impostazioni** → **Applicazioni** → **Genera nuovo token** +3. Nome suggerito: `loogle-mcp` +4. Scope minimi: + - `read:repository` — list_repos, get_file, search_code, list_issues, get_issue, RAG + - `write:repository` — **P4:** create_or_update_file + - `write:user` — **P4:** create_gitea_repo (`POST /user/repos`) + - `write:issue` — create_issue + - `read:user` — consigliato per `/user/repos` +5. Copia il token (mostrato una sola volta) in `.env` + +### Alternativa CLI (admin, sul nodo con Gitea attivo) + +```bash +docker exec -u git gitea gitea admin user generate-access-token \ + --username daniele \ + --token-name loogle-mcp \ + --scopes 'read:repository,write:repository,write:issue,write:user,read:user' +``` + +--- + +## Configurazione in Loogle MCP + +Modifica `/home/daniely/docker/loogle-mcp/.env`: + +```env +GITEA_URL=https://git.loogle.it +GITEA_API_TOKEN_DANIELE= +GITEA_API_TOKEN_LUCIA= +GITEA_API_TOKEN_DAVIDE= +GITEA_API_TOKEN_LUCA= +``` + +| Utente MCP | Variabile `.env` | Account Gitea | +|------------|------------------|---------------| +| Daniele | `GITEA_API_TOKEN_DANIELE` | daniele | +| Lucia | `GITEA_API_TOKEN_LUCIA` | lucia | +| Davide | `GITEA_API_TOKEN_DAVIDE` | davide | +| Luca | `GITEA_API_TOKEN_LUCA` | luca | + +### URL API interno (opzionale) + +Se `https://git.loogle.it` non è raggiungibile dal container MCP (rete LAN, failover), imposta un URL diretto: + +```env +GITEA_API_URL=http://192.168.128.81:3002 +``` + +I link nei risultati restano su `GITEA_URL` pubblico. + +### Fallback admin + +```env +GITEA_API_TOKEN= +``` + +Tutti gli utenti MCP useranno lo stesso token (sconsigliato salvo test). + +--- + +## Tool MCP Gitea + +| Tool | Scope | Descrizione | +|------|-------|-------------| +| `list_repos` | gitea:read | Repository accessibili | +| `get_file` | gitea:read | Legge file o elenca directory | +| `search_code` | gitea:read | Cerca nel codice (API `/search/code` o fallback tree) | +| `list_issues` | gitea:read | Issue aperte/chiuse | +| `get_issue` | gitea:read | Dettaglio issue | +| `create_issue` | gitea:write | Crea issue | +| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP corrente | +| `create_or_update_file` | gitea:write | Commit singolo di un file (create/update via Contents API) | + +Dopo modifica `.env`: + +```bash +cd /home/daniely/docker/loogle-mcp && docker compose up -d --build +``` + +--- + +## Verifica + +```bash +/home/daniely/docker/loogle-mcp/scripts/test_gitea_integration.py +``` + +Oppure da MCP (con token OAuth): tool `list_repos` e `get_file` su `daniele/rete`. + +--- + +## Collegamento progetto ↔ repository (P2) + +Ogni progetto MCP può referenziare un repo Gitea in `meta.json`: + +```json +"gitea_repo": "daniele/rete" +``` + +### Tool + +| Tool | Azione | +|------|--------| +| `create_project` | Parametri opzionali `gitea_repo`, `seed_from_gitea` | +| `link_project_repo` | Collega, cambia repo o scollega (`gitea_repo` vuoto) | +| `get_project_context` | Aggiunge blocco `gitea` con README, `docs/` e issue aperte | + +### Seed README + +Con `seed_from_gitea: true` il README del repo viene importato in `context.md` (una sola volta, marker ``). + +### Verifica P2 + +```bash +/home/daniely/docker/loogle-mcp/scripts/test_project_gitea_p2.py +``` + +--- + +## RAG su repository Gitea (P3) + +Il worker `loogle-mcp-indexer` indicizza periodicamente file testo dai repo Gitea (`.md`, `.py`, `.sh`, …) in collection Qdrant dedicate: + +| Collection | Contenuto | +|------------|-----------| +| `gitea_shared_family` | Repo non privati | +| `gitea_personal_{user}` | Repo privati dell'owner | + +### Config `.env` + +```env +GITEA_INDEX_ENABLED=yes +GITEA_INDEX_REPOS_DANIELE=daniele/rete,daniele/loogle-scripts +GITEA_INDEX_MAX_FILES_PER_REPO=150 +GITEA_INDEX_MAX_FILE_BYTES=120000 +``` + +Se `GITEA_INDEX_REPOS_{USER}` è vuoto → tutti i repo accessibili via API. + +### Tool MCP + +| Tool | Descrizione | +|------|-------------| +| `search_gitea_knowledge` | Ricerca semantica solo su Gitea | +| `search_knowledge` | Include Paperless + contesti + Gitea | +| `list_gitea_indexed_files` | Stato indicizzazione | +| `reindex_gitea_repo` | Forza re-index di un repo | + +### Verifica P3 + +```bash +docker exec loogle-mcp python3 /srv/scripts/test_gitea_rag_p3.py +``` + +--- + +## Scrittura su Gitea (P4) + +L'AI può creare repository e committare singoli file via API Gitea (senza git push locale). + +| Tool | Scope | Azione | +|------|-------|--------| +| `create_gitea_repo` | gitea:write | Crea repo sotto l'utente MCP autenticato | +| `create_or_update_file` | gitea:write | Crea o aggiorna un file (Contents API + commit) | + +Regole: + +- Scrittura consentita solo su repo di cui l'utente è **owner** (salvo admin MCP). +- Dopo scrittura, opzionalmente re-indicizza il file nel RAG (`reindex: true` default). +- Token Gitea: `write:user` (create repo) + `write:repository` (file). + +### Workspace davide e luca + +| Utente | Repo Gitea | Progetto MCP | +|--------|------------|--------------| +| davide | `davide/progetti` | Progetti Davide | +| luca | `luca/progetti` | Progetti Luca | + +Setup iniziale: + +```bash +docker exec loogle-mcp python3 /srv/scripts/setup_gitea_p4_repos.py +``` + +### Verifica P4 + +```bash +docker exec loogle-mcp python3 /srv/scripts/test_gitea_p4.py +``` + diff --git a/services/loogle-mcp/docs/GUIDA-ADMIN.md b/services/loogle-mcp/docs/GUIDA-ADMIN.md new file mode 100644 index 0000000..acbca09 --- /dev/null +++ b/services/loogle-mcp/docs/GUIDA-ADMIN.md @@ -0,0 +1,190 @@ +# Guida amministratore — Daniele + +Guida operativa per gestire e usare Loogle MCP Hub. + +--- + +## Accesso rapido + +| Cosa | URL / comando | +|------|----------------| +| Dashboard admin | https://mcp.loogle.it/dashboard (login `daniele`) | +| Health | `curl -sf https://mcp.loogle.it/health` | +| Log gateway | `docker logs -f loogle-mcp` | +| Log indexer | `docker logs -f loogle-mcp-indexer` | +| Compose | `cd /home/daniely/docker/loogle-mcp` | + +--- + +## Configurazione iniziale (checklist) + +- [x] Stack Docker avviato (`docker compose up -d`) +- [x] DNS Pi-hole: `mcp.loogle.it` → `192.168.128.85` +- [x] NPM proxy host 30 + TLS Let's Encrypt +- [ ] Token Paperless in `.env` — vedi [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) (Profilo utente, non «Applicazione social») +- [ ] `MCP_JWT_SECRET` robusto in `.env` +- [ ] Ollama su DS920 con `ollama pull nomic-embed-text` +- [ ] Qdrant su DS920 (opzionale, vedi compose extrema) +- [ ] Password cambiate per tutta la famiglia +- [ ] Ogni familiare ha collegato almeno un client AI + +**Script utili:** +```bash +sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh # DNS + NPM +./scripts/deploy.sh # rebuild + restart +/home/daniely/rete/scripts/npm-failover-route.sh normal # upstream failover +``` + +--- + +## Come usare tu (Daniele) + +### Dashboard + +1. https://mcp.loogle.it/dashboard — login `daniele` +2. Vedi progetti, audit log completo, revoca refresh token (sezione admin) + +### Con Cursor / Claude / ChatGPT + +Stesso URL MCP: `https://mcp.loogle.it/mcp` + +**Cursor** — aggiungi in `.cursor/mcp.json` del progetto: +```json +{ + "mcpServers": { + "loogle-mcp": { + "url": "https://mcp.loogle.it/mcp" + } + } +} +``` + +### Workflow consigliato per un task + +1. **create_project** — es. «Homelab MCP», tag `rete`, `2026` +2. Lavori con l’AI (Cursor, Claude, ecc.) +3. **save_context** — a fine sessione salvi decisioni e stato +4. **search_knowledge** — recuperi doc da Paperless quando serve +5. **search_context** — ritrovi discussioni passate semanticamente + +### Tool solo admin + +- `reindex_document` — forza re-indicizzazione di un doc Paperless +- Revoca token refresh da dashboard +- Audit log di tutti gli utenti via `/api/audit` + +--- + +## Gestione utenti + +Gli utenti sono in SQLite (`loogle_mcp.db`). Password hash PBKDF2. + +| Utente | Admin | Reset password | +|--------|-------|----------------| +| daniele | sì | dashboard o diretto DB | +| lucia, davide, luca | no | dashboard (self-service) | + +Password iniziale = username. `must_change_password` è attivo al primo login consigliato. + +Per aggiungere utenti in futuro: estendi `FAMILY_USERS` in `app/auth.py` e redeploy. + +--- + +## Paperless → knowledge base + +Vedi guida completa: [PAPERLESS-TOKEN.md](PAPERLESS-TOKEN.md) + +**Non usare** i token «Applicazione social» dell'admin Django (schermata nell'immagine): servono al login OAuth web di Paperless, non all'API REST MCP. + +**Usa** i token API utente: docs.loogle.it → menu utente → **Profilo** → pulsante freccia circolare accanto a «Token API». + +Configura in `.env` (un token per ogni account Paperless della famiglia): + +```bash +PAPERLESS_API_TOKEN_DANIELE= +PAPERLESS_API_TOKEN_LUCIA= +PAPERLESS_API_TOKEN_DAVIDE= +PAPERLESS_API_TOKEN_LUCA= +``` + +Ogni familiare genera il proprio token da docs.loogle.it → **Profilo** → Token API. Se un utente non ha ancora account Paperless, lascia la riga vuota finché non lo crei. + +Alternativa: solo `PAPERLESS_API_TOKEN=` con token admin (Daniele superuser) — tutti gli utenti MCP condividono lo stesso scope. + +Riavvia indexer: +```bash +cd /home/daniely/docker/loogle-mcp && docker compose restart loogle-mcp loogle-mcp-indexer +``` + +**Visibilità documenti (tag Paperless):** +- Tag `personal` / `privato` → solo collection personale del owner +- Senza tag personali → `kb_shared_family` (tutti in famiglia) + +--- + +## Embedding e Qdrant + +| Componente | Dove | Note | +|----------|------|------| +| Ollama | DS920 `:11434` | `OLLAMA_EMBED_MODEL=nomic-embed-text` | +| Qdrant | DS920 `:6333` | Non gira su Pi5 (limitazione ARM) | +| Fallback | `/data/vector_fallback.db` | Automatico se Qdrant non raggiungibile | + +Deploy Qdrant su DS920: +```bash +# sul DS920, path mirror extrema +docker compose -f /volume1/extrema/compose/extrema-ds920/loogle-mcp-compose.yml up -d qdrant +``` + +--- + +## DNS e NPM — cosa è stato configurato + +**Pi-hole** (`/etc/pihole/pihole.toml`): +``` +"192.168.128.85 mcp.loogle.it" +``` + +**NPM** proxy host ID 30: +- Dominio: `mcp.loogle.it` +- Backend: `192.168.128.80:8700` +- Cert: `npm-65` + +**Failover** (`npm-routes.conf`): +``` +30|mcp.loogle.it|8700|pi1|pi1|local|ds920 +``` + +Se reinstalli NPM da zero, riesegui: +```bash +sudo /home/daniely/rete/scripts/setup-mcp-dns-npm.sh +/home/daniely/sync-npm-full.sh # sync Pi2 + DS920 +``` + +--- + +## Monitoraggio e backup + +- **Failover status:** probe `mcp_ok` / `mcp_be` in `failover-status.sh` +- **Runbook incidenti:** `/home/daniely/rete/ha/RUNBOOK-mcp.md` +- **Backup restic:** `/home/daniely/rete/infra-monitor/mcp-restic-backup.sh` + +--- + +## Onboarding famiglia + +Invia a ciascuno: + +- [Guida utenti](GUIDA-UTENTI.md) — istruzioni semplici per ChatGPT/Claude/Gemini +- URL dashboard per cambio password +- Username personale (non condividere password) + +Progetto demo già creato per ogni utente (es. `progetti-personali` per Lucia). + +--- + +## Documentazione tecnica + +- [Architettura e parametri](ARCHITETTURA.md) +- [Onboarding client AI (dettaglio)](ONBOARDING.md) +- [Runbook HA](/home/daniely/rete/ha/RUNBOOK-mcp.md) diff --git a/services/loogle-mcp/docs/GUIDA-PAPERLESS-FAMIGLIA.md b/services/loogle-mcp/docs/GUIDA-PAPERLESS-FAMIGLIA.md new file mode 100644 index 0000000..efebb62 --- /dev/null +++ b/services/loogle-mcp/docs/GUIDA-PAPERLESS-FAMIGLIA.md @@ -0,0 +1,237 @@ +# Paperless — Guida per la famiglia LOOGLE + +**Archivio documenti di casa:** https://docs.loogle.it + +Questa guida spiega **perché** usiamo Paperless, **come** configurarlo e **come** installare l’app sul telefono. + +--- + +## Perché usiamo Paperless + +Paperless è l’**archivio digitale di casa**: bollette, contratti, manuali, referti, ricevute, documenti scolastici, ecc. + +| Prima (cartaceo) | Con Paperless | +|------------------|---------------| +| Documenti sparsi in cassetti e cartelle | Tutto in un unico archivio searchable | +| Difficile trovare «la bolletta luce del 2024» | Cerca per parola, tag, mittente, data | +| Fotocopie e PDF persi nel telefono | PDF centralizzati, backup su NAS di casa | +| Solo chi ha il foglio fisico | Chi ha l’account può consultare (con permessi) | + +**Vantaggi per noi:** + +- **Resta in casa** — i file sono sui nostri dispositivi (Pi + NAS), non su Google Drive o iCloud di terzi +- **OCR in italiano** — Paperless legge il testo nei PDF e nelle scansioni, così puoi cercare anche dentro il documento +- **Integrazione** — l’assistente AI (MCP) può cercare documenti già archiviati quando chiedi «trova la bolletta gas» +- **Accesso da telefono** — consulta e carica documenti ovunque (in rete casa o via VPN) + +--- + +## Cos’è, in pratica + +Paperless-ngx è un sito web dove: + +1. **Carichi** un PDF o una foto (upload, email, cartella sul telefono) +2. **Paperless** fa OCR, propone titolo, data, tag +3. **Tu** confermi o correggi (opzionale) +4. **Archivi** — il documento resta indicizzato e ricercabile + +**Indirizzo:** https://docs.loogle.it +Funziona da browser (PC, tablet, telefono) o da app dedicata. + +--- + +## Account famiglia + +| Nome | Username | Password iniziale | +|------|----------|-------------------| +| Daniele | `daniele` | *(chiedi a Daniele)* | +| Lucia | `lucia` | `lucia` | +| Davide | `davide` | `davide` | +| Luca | `luca` | `luca` | + +**Al primo accesso cambia la password** (vedi sotto). + +Ogni persona vede i **propri** documenti e quelli **condivisi in famiglia** (secondo i permessi impostati). + +--- + +## Primo accesso e configurazione (browser) + +### 1. Accedi al sito + +1. Apri **https://docs.loogle.it** (da casa o in VPN — vedi sotto) +2. Inserisci **username** e **password** +3. Se è la prima volta, Paperless può chiederti di completare il profilo + +### 2. Cambia la password + +1. Clicca sul **nome utente** in alto a destra +2. Vai su **Profilo** / **Impostazioni** +3. Sezione **Password** → inserisci la vecchia e la nuova +4. Salva + +### 3. Conosci l’interfaccia + +| Area | A cosa serve | +|------|----------------| +| **Dashboard** | Panoramica, documenti recenti | +| **Documenti** | Elenco e ricerca di tutti i PDF | +| **Inbox** (se attiva) | Documenti appena caricati da revisionare | +| **Tag / Tipi / Corrispondenti** | Organizzazione (es. tag `bollette`, `salute`) | + +### 4. Caricare un documento + +**Da computer:** + +1. **Documenti** → **Carica** (o trascina il PDF nella finestra) +2. Attendi l’OCR (qualche secondo) +3. Controlla titolo, data, tag → **Salva** + +**Da telefono (browser):** + +- Stesso procedimento dal browser mobile su docs.loogle.it + +**Suggerimento:** usa tag semplici e coerenti, es. `bollette`, `casa`, `auto`, `salute`, `scuola`. + +### 5. Cercare un documento + +- Barra **Cerca** in alto: parole chiave (es. «enel», «bolletta», «assicurazione») +- Filtri per **tag**, **tipo documento**, **data** +- Paperless cerca anche **dentro** il testo del PDF (OCR) + +### 6. Scaricare o condividere + +- Apri un documento → icona **Download** o **Condividi** +- Puoi inviare il PDF via email/WhatsApp dal telefono + +--- + +## Accesso da fuori casa (VPN) + +`docs.loogle.it` è raggiungibile **in rete di casa** (Wi‑Fi domestico o dati se sei a casa). + +**Se sei fuori** (ufficio, viaggio): connettiti prima alla **VPN di casa**: + +- Indirizzo: **https://vpn.loogle.it** +- Credenziali WireGuard: chiedi a Daniele (file `.conf` o QR code) + +Dopo la VPN, apri normalmente https://docs.loogle.it o l’app Paperless. + +--- + +## App sul telefono + +Paperless **non ha un’app ufficiale** del team Paperless-ngx, ma esistono app **compatibili** che si collegano al nostro server. + +### Consigliate + +| App | Android | iPhone/iPad | +|-----|---------|-------------| +| **Paperless Mobile** | [Google Play](https://play.google.com/store/apps/details?id=de.astubenbord.paperless_mobile) | Cerca «Paperless Mobile» su App Store | +| **Paperless Go** | [Google Play](https://play.google.com/store/apps/details?id=com.github.iweinzierl.paperlessgo) / F-Droid | [App Store](https://apps.apple.com/app/paperless-go/id6738696198) | + +Per la famiglia consigliamo **Paperless Mobile** (molto usata, italiano supportato) oppure **Paperless Go** (interfaccia moderna, scansione documenti). + +--- + +## Installazione app — passo passo + +### Android (Paperless Mobile) + +1. Apri **Google Play Store** +2. Cerca **«Paperless Mobile»** (sviluppatore: Andrei Stübenbord) +3. **Installa** +4. Apri l’app → **Aggiungi account** / **Connect** +5. **URL server:** `https://docs.loogle.it` +6. **Username** e **password** (le tue credenziali Paperless) +7. Accetta eventuale avviso certificato (connessione sicura LOOGLE) +8. Fatto — vedi l’elenco documenti + +### iPhone / iPad (Paperless Mobile o Paperless Go) + +1. Apri **App Store** +2. Cerca **«Paperless Mobile»** o **«Paperless Go»** +3. **Scarica** e apri +4. Inserisci: + - **Server URL:** `https://docs.loogle.it` + - **Username** / **Password** +5. Opzionale: attiva **Face ID / Touch ID** per proteggere l’app +6. Fatto + +### Scansione con il telefono (Paperless Go) + +1. Nell’app → **Scansiona** / icona fotocamera +2. Inquadra il documento (bolletta, lettera) +3. Ritaglia e conferma +4. Scegli tag / tipo se richiesto → **Carica** +5. Il PDF compare in Paperless dopo pochi secondi + +--- + +## Configurazione consigliata nell’app + +| Impostazione | Consiglio | +|--------------|-----------| +| **Tema scuro** | Più comodo di sera | +| **Biometria** | Face ID / impronta per aprire l’app | +| **Notifiche** | Opzionali (nuovi documenti in inbox) | +| **Account multipli** | Un account per persona (non condividere la stessa login) | + +--- + +## Buone abitudini + +1. **Carica subito** bollette e documenti importanti (non lasciare pile di carta) +2. **Usa tag** — pochi ma chiari (`luce`, `gas`, `banca`, `medico`) +3. **Controlla l’inbox** — correggi titolo/data se Paperless sbaglia +4. **Non eliminare** l’originale cartaceo finché non sei sicuro che il PDF sia ok +5. **Cambia password** se pensi che qualcuno l’abbia vista + +--- + +## Collegamento con l’assistente AI (MCP) + +Se usi ChatGPT, Claude o Cursor collegati a **Loogle MCP** (`https://mcp.loogle.it`): + +- Puoi chiedere: *«Cerca in Paperless la bolletta luce dell’ultimo trimestre»* +- L’AI cerca solo nei documenti **che il tuo account può vedere** +- Per funzionare, i documenti devono **già essere** in Paperless + +La guida MCP è separata: `GUIDA-UTENTI.pdf` nella stessa cartella. + +--- + +## Problemi comuni + +| Problema | Cosa fare | +|----------|-----------| +| «Sito non raggiungibile» | Sei in rete casa? Se no, attiva **VPN** (vpn.loogle.it) | +| Login fallisce | Controlla username/password; prova da browser prima | +| App «connessione fallita» | URL esatto: `https://docs.loogle.it` (con **https**) | +| Certificato / SSL | Accetta il certificato LOOGLE; data/ora telefono corretta | +| Non vedo un documento | Potrebbe essere personale di un altro utente; chiedi a Daniele | +| OCR illeggibile | Scansiona con buona luce; PDF meglio di foto storta | + +--- + +## Contatti + +**Daniele** — account, permessi, VPN, problemi tecnici. + +--- + +## Scheda rapida + +``` +Sito: https://docs.loogle.it +VPN fuori: https://vpn.loogle.it +App Android: Paperless Mobile (Play Store) +App iPhone: Paperless Mobile o Paperless Go (App Store) +Server URL: https://docs.loogle.it +Login: il tuo username (lucia / davide / luca) +Primo passo: cambia password → installa app → carica una bolletta di prova +``` + +--- + +*Loogle — documenti di famiglia, a casa nostra.* diff --git a/services/loogle-mcp/docs/GUIDA-UTENTI.md b/services/loogle-mcp/docs/GUIDA-UTENTI.md new file mode 100644 index 0000000..96dd0ab --- /dev/null +++ b/services/loogle-mcp/docs/GUIDA-UTENTI.md @@ -0,0 +1,289 @@ +# Guida utenti — Loogle MCP Hub + +Questa guida è per **Lucia, Davide e Luca** (e chiunque usi gli assistenti AI collegati a casa). + +--- + +## Cos’è + +Loogle MCP è la **memoria di casa** per i tuoi assistenti AI. Permette a ChatGPT, Claude o Gemini di: + +- **Ricordare** i tuoi progetti (es. ristrutturazione, studio, hobby) +- **Cercare** documenti che abbiamo già archiviato in Paperless (bollette, manuali, PDF) +- **Salvare** note e riassunti che l’AI produce durante un lavoro, così non devi ripetere tutto ogni volta + +I tuoi dati restano **solo sui nostri dispositivi** a casa, non nel cloud dell’AI. + +--- + +## Le tue credenziali + +| Nome | Username | Password iniziale | +|------|----------|-------------------| +| Lucia | `lucia` | `lucia` | +| Davide | `davide` | `davide` | +| Luca | `luca` | `luca` | + +**Al primo accesso cambia la password:** + +1. Apri https://mcp.loogle.it/dashboard +2. Accedi con username e password +3. Sezione **Cambia password** → inserisci la nuova password (minimo 6 caratteri) + +Ogni persona vede **solo i propri progetti**. Non puoi vedere (né l’AI) i progetti degli altri familiari. + +--- + +## Collegare ChatGPT + +Richiede account **Plus, Pro, Business o Edu**. + +1. Apri ChatGPT → **Impostazioni** +2. Attiva **Developer Mode** (se non già attivo) +3. Vai a **Connectors** → **Add MCP Connector** +4. Inserisci l’URL del server: + ``` + https://mcp.loogle.it/mcp + ``` +5. Clicca **Connect** — si apre una pagina di login Loogle +6. Accedi con **il tuo** username e password (es. `lucia` / la tua nuova password) +7. Autorizza l’accesso + +Nelle conversazioni, abilita i tool del connector **Loogle MCP** quando vuoi usare memoria o documenti. + +### Esempi di richieste a ChatGPT + +- *«Usa list_projects per vedere i miei progetti»* +- *«Leggi il contesto del progetto X e riassumilo»* +- *«Cerca nei documenti di casa informazioni sulla caldaia»* +- *«Salva in save_context questo riepilogo del lavoro fatto oggi»* + +--- + +## Collegare Claude (App / Desktop) + +1. Apri Claude → **Settings** → **Connectors** +2. **Add remote MCP server** +3. URL: + ``` + https://mcp.loogle.it/mcp + ``` +4. Al primo utilizzo: login con le **tue** credenziali Loogle MCP +5. Autorizza + +Claude potrà usare gli stessi tool (progetti, ricerca documenti, salvataggio contesto). + +--- + +## Collegare Cursor IDE + +1. Crea o modifica `~/.cursor/mcp.json` (globale) oppure `.cursor/mcp.json` nel progetto: + +```json +{ + "mcpServers": { + "loogle-mcp": { + "url": "https://mcp.loogle.it/mcp", + "auth": { + "CLIENT_ID": "cursor", + "scopes": [ + "context:read", "context:write", + "knowledge:read", "knowledge:write", + "gitea:read", "gitea:write", + "home:read", "irrigation:read", "turni:read" + ] + } + } + } +} +``` + +2. **Riavvia Cursor completamente** (esci dall’app, non solo chiudi la finestra) +3. **Cursor Settings** → **Tools & MCP** → **Connect** su `loogle-mcp` (**una sola volta**, attendi 10–15 s) +4. Si apre il browser su **Loogle MCP — Login** → accedi con le **tue** credenziali MCP +5. In chat, chiedi all'AI di usare i tool (`list_projects`, `search_knowledge`, …) + +### Cursor: «Unauthorized» nei log + +| Log Cursor | Significato | +|------------|-------------| +| `MCP OAuth redirect` + `Unauthorized` | **Normale prima del login** — clicca **Connect** e completa il browser | +| `Redirect URI non consentito` | Errore server (già risolto) — riprova **Reconnect** | +| `credentials cleared` | Hai cliccato Connect/Disconnect in loop — riavvia Cursor e riprova una volta | + +**Password:** se hai cambiato quella iniziale, usa quella attuale (es. admin `daniele` non usa più `daniele`/`daniele`). Verifica su https://mcp.loogle.it/dashboard + +**Se il browser non si apre:** prova la config con `"CLIENT_ID": "cursor"` sopra, oppure apri manualmente https://mcp.loogle.it/dashboard per testare le credenziali. + +--- + +## Collegare Gemini + +**Gemini CLI** (da terminale / PC): + +1. Apri il file di configurazione MCP di Gemini (es. `~/.gemini/settings.json`) +2. Aggiungi: + +```json +{ + "mcpServers": { + "loogle-mcp": { + "url": "https://mcp.loogle.it/mcp" + } + } +} +``` + +3. Al primo avvio completa il login OAuth nel browser con le tue credenziali + +L’app Gemini mobile potrebbe non supportare ancora MCP remoto; in quel caso usa ChatGPT o Claude. + +--- + +## Dashboard web + +Indirizzo: **https://mcp.loogle.it/dashboard** + +Da qui puoi: + +- Vedere l’elenco dei **tuoi progetti** +- **Cambiare password** +- Consultare le **ultime azioni** che l’AI ha fatto (audit log) + +--- + +## Come usarlo nel quotidiano + +### 1. Crea un progetto per ogni tema importante + +Chiedi all’AI: + +> «Crea un progetto chiamato "Rinnovo bagno" con tag casa, 2026» + +(Usa il tool `create_project` — l’AI lo farà automaticamente se il connector è attivo.) + +### 2. Lavora e fai salvare la memoria + +A fine sessione: + +> «Salva in save_context un riepilogo di quello che abbiamo deciso oggi, nel progetto rinnovo-bagno» + +La prossima volta l’AI potrà rileggere tutto con `get_project_context`. + +### 3. Cerca documenti di casa + +> «Cerca in search_knowledge la bolletta luce dell’ultimo trimestre» + +> «Trova nel archivio documenti il manuale della caldaia» + +(I documenti devono essere già in Paperless su docs.loogle.it.) + +### 4. Cerca tra i tuoi appunti passati + +> «search_context: cosa avevamo deciso sul parquet?» + +### 5. Codice e runbook su Gitea + +> «list_repos: quali repository ho su git.loogle.it?» + +> «get_file su daniele/rete ha/RUNBOOK-failover.md» + +> «search_code tier-b nel repo daniele/rete» + +### 6. Progetto MCP collegato al codice + +> «create_project "Infra Rete" con gitea_repo daniele/rete e seed_from_gitea true» + +> «link_project_repo sul progetto homelab-loogle → daniele/rete» + +> «get_project_context sul progetto attivo» — include README e elenco file in `docs/` dal repo collegato + +> «search_gitea_knowledge: come funziona il failover tier-b?» + +> «reindex_gitea_repo daniele/rete» — forza aggiornamento indicizzazione + +### 7. Ricerca semantica su runbook Git (RAG) + +> «search_gitea_knowledge failover su daniele/rete» + +> «search_knowledge keepalived VIP» — cerca anche in Paperless e Gitea indicizzati + +L'indexer in background indicizza `.md`, script e config dai repo collegati (vedi `docs/GITEA-TOKEN.md` sezione P3). + +### 8. Casa, rete e Home Assistant (P5) + +> «get_home_weather: che tempo fa a casa?» + +> «get_network_overview: qualcosa è offline in rete?» + +> «get_network_failover_status: com'è lo scenario failover?» + +> «get_ha_entity switch.pompa_pozzo» — stato entità Home Assistant (solo lettura) + +> «search_ha_entities caldaia» + +### 9. Irrigazione e turni (P6) + +> «get_irrigation_status: programma irrigazione oggi» + +> «get_irrigation_zones: quali zone sono attive?» + +> «get_my_shifts: i miei turni di lavoro questo mese» + +> «list_turni_doctors» — elenco medici in Turni-Live + +### 10. Ricerca storica app (P7) + +> «search_apps_knowledge irrigazione prato ieri» + +> «search_knowledge valvola pozzo» — include anche Irrigazione/Turni indicizzati + +Dettagli tecnici e configurazione: `docs/APPS-INTEGRATION.md`. + +--- + +## Cosa può e non può fare + +| Può | Non può | +|-----|---------| +| Leggere i **tuoi** progetti | Vedere progetti di altri familiari | +| Cercare documenti **famiglia** e **personali** (secondo tag Paperless) | Modificare Paperless o cancellare PDF | +| Aggiungere testo alla **tua** memoria progetto | Accedere senza login OAuth | +| Cercare semanticamente (capisce il significato, non solo parole esatte) | Funzionare senza internet verso casa* | +| Leggere codice e issue da **Gitea** (repo a cui hai accesso) | Push/commit git via MCP (usa git normalmente) | +| Meteo/rete da **Loogle Casa**, sensori **Home Assistant** (lettura) | Controllare luci/valvole via MCP (solo lettura HA) | +| Stato **irrigazione** e **turni** (se configurati per te) | Modificare programmi irrigazione o turni via MCP | + +\*Da fuori casa serve VPN WireGuard (`vpn.loogle.it`) o connessione alla rete di casa. + +--- + +## Problemi comuni + +| Problema | Cosa fare | +|----------|-----------| +| Login fallisce | Verifica username/password; reset da dashboard | +| ChatGPT non trova il server | URL esatto: `https://mcp.loogle.it/mcp` (con https) | +| «Knowledge vuota» | I documenti Paperless potrebbero non essere ancora indicizzati — chiedi a Daniele | +| Token scaduto | Riconnetti il connector OAuth (disconnect + connect) | +| Cursor «Unauthorized» | Clicca **Connect** una volta; login browser; usa config con `"CLIENT_ID": "cursor"` | +| Sito non raggiungibile | Sei in VPN/rete casa? Prova https://mcp.loogle.it/health | + +--- + +## Contatti + +Per problemi tecnici (token Paperless, servizio down, nuovo progetto di gruppo): **Daniele**. + +Per la password dimenticata: chiedi a Daniele (admin) o usa il dashboard se ricordi quella vecchia. + +--- + +## Scheda rapida + +``` +URL MCP: https://mcp.loogle.it/mcp +Dashboard: https://mcp.loogle.it/dashboard +Login: il tuo username (lucia / davide / luca) +Primo passo: cambia password → collega ChatGPT o Claude → crea un progetto +``` diff --git a/services/loogle-mcp/docs/ONBOARDING.md b/services/loogle-mcp/docs/ONBOARDING.md new file mode 100644 index 0000000..d4eadce --- /dev/null +++ b/services/loogle-mcp/docs/ONBOARDING.md @@ -0,0 +1,157 @@ +# Onboarding client AI — Loogle MCP Hub + +Endpoint MCP: `https://mcp.loogle.it/mcp` + +OAuth: al primo collegamento compare la pagina login Loogle. Usa le credenziali famiglia. + +Password iniziale = username (es. `lucia` / `lucia`). Cambiala da https://mcp.loogle.it/dashboard + +--- + +## Daniele (admin) + +- Username: `daniele` +- Tool admin: `reindex_document`, audit completo, revoca token refresh + +## Lucia + +- Username: `lucia` +- Progetti isolati in `/mnt/ha-apps/mcp/context/lucia/` + +## Davide + +- Username: `davide` + +## Luca + +- Username: `luca` + +--- + +## ChatGPT (Plus/Pro/Business) + +1. Abilita **Developer Mode** nelle impostazioni ChatGPT +2. Settings → Connectors → **Add MCP Connector** +3. URL server: `https://mcp.loogle.it/mcp` +4. Completa OAuth con le tue credenziali +5. Abilita i tool desiderati nella conversazione + +Redirect URI supportati (pre-registrati): +- `https://chatgpt.com/connector_platform_oauth_redirect` +- `https://chat.openai.com/connector_platform_oauth_redirect` + +## Claude App / Claude Desktop + +1. Settings → Connectors → **Add remote MCP server** +2. URL: `https://mcp.loogle.it/mcp` +3. Autenticazione OAuth al primo uso + +Redirect URI: `https://claude.ai/api/mcp/auth_callback` + +## Cursor IDE + +Cursor supporta MCP remoto con **OAuth automatico** (Streamable HTTP). + +### Configurazione + +**Globale** (tutti i progetti): `~/.cursor/mcp.json` +**Solo questo repo**: `.cursor/mcp.json` (già presente nel progetto loogle-mcp) + +```json +{ + "mcpServers": { + "loogle-mcp": { + "url": "https://mcp.loogle.it/mcp" + } + } +} +``` + +### Collegamento + +1. Apri **Cursor Settings** → **Tools & MCP** +2. Trova **loogle-mcp** → clic **Connect** +3. Si apre il browser → login con le **tue** credenziali Loogle (`daniele`, `lucia`, …) +4. Autorizza → Cursor torna con i tool attivi + +Redirect OAuth Cursor (già registrati sul server): +- `https://www.cursor.com/agents/mcp/oauth/callback` (web / Agents) +- `http://localhost:8787/callback` (desktop app) + +### Uso in chat + +Chiedi all'agente di usare i tool MCP, ad esempio: + +> «Usa whoami e list_projects per vedere i miei progetti Loogle» + +Se il server risulta disconnesso: **Reconnect** da Tools & MCP. + +--- + +## Gemini CLI + +Aggiungi in `~/.gemini/settings.json` (o equivalente): + +```json +{ + "mcpServers": { + "loogle": { + "url": "https://mcp.loogle.it/mcp", + "transport": "http" + } + } +} +``` + +Al primo avvio completa il flow OAuth nel browser. + +## Cursor IDE + +File `.cursor/mcp.json` nel progetto: + +```json +{ + "mcpServers": { + "loogle-mcp": { + "url": "https://mcp.loogle.it/mcp" + } + } +} +``` + +--- + +## Tool disponibili + +| Tool | Descrizione | +|------|-------------| +| `ping` | Test connettività | +| `whoami` | Utente e scope | +| `list_projects` | Progetti utente | +| `create_project` | Nuovo archivio contesto | +| `get_project_context` | Legge memoria progetto | +| `save_context` | Salva/appende memoria | +| `search_context` | Ricerca semantica contesti | +| `search_knowledge` | RAG su Paperless + contesti + Gitea indicizzati | +| `search_gitea_knowledge` | RAG solo su file Gitea (runbook, markdown) | +| `list_gitea_indexed_files` | File Gitea nel vector store | +| `reindex_gitea_repo` | Re-indicizza un repo (admin/owner) | +| `get_document` | Testo documento Paperless | +| `list_recent_documents` | Ultimi doc indicizzati | +| `reindex_document` | Solo admin | +| `list_repos` | Repository Gitea accessibili | +| `get_file` | Legge file da repo Gitea | +| `search_code` | Cerca nel codice sorgente | +| `list_issues` / `get_issue` | Issue su repo Gitea | +| `create_issue` | Crea issue (scope gitea:write) | +| `link_project_repo` | Collega/scollega repo Gitea a un progetto | + +## Esempio prompt agente + +> "Usa list_projects per vedere i miei progetti, poi get_project_context sul progetto attivo e search_knowledge per trovare documenti rilevanti su [argomento]." + +## Troubleshooting + +- **401 su MCP**: token scaduto — riconnetti il connector OAuth +- **Knowledge vuota**: verifica token Paperless e Ollama su DS920 +- **Dashboard**: https://mcp.loogle.it/dashboard diff --git a/services/loogle-mcp/docs/PAPERLESS-TOKEN.md b/services/loogle-mcp/docs/PAPERLESS-TOKEN.md new file mode 100644 index 0000000..5fcde5b --- /dev/null +++ b/services/loogle-mcp/docs/PAPERLESS-TOKEN.md @@ -0,0 +1,108 @@ +# Token Paperless per Loogle MCP + +## Cosa NON usare: token «Applicazione social» + +Nell'admin Django di Paperless (`/admin/socialaccount/socialtoken/`) compare il form **«Aggiungi token dell'applicazione social»** con campi Token, Token segreto, Scade il. + +Questi token servono a **django-allauth** per il login OAuth web (Google, Microsoft, ecc.) su Paperless. **Non** sono token dell'API REST di Paperless e **non** vanno usati per Loogle MCP. + +Non devi configurare nulla in quella schermata per il nostro hub MCP. + +--- + +## Cosa usare: token API utente + +Paperless espone un'API REST autenticata con header: + +``` +Authorization: Token +``` + +Ogni utente Paperless ha il proprio token, legato ai permessi di quell'account (documenti visibili, tag, ecc.). + +### Dove generarlo + +Per **ogni** familiare con account Paperless (daniele, lucia, davide, luca): + +1. Accedi a https://docs.loogle.it con quell'account +2. Menu utente (in alto a destra) → **Profilo** / **Il mio profilo** +3. Sezione **Token API** → pulsante freccia circolare (rigenera token) +4. Copia il token nella riga corrispondente di `.env` + +Alternativa admin: Django admin → **Token** (app `authtoken`), associato all'utente. + +--- + +## Configurazione in Loogle MCP + +Modifica `/home/daniely/docker/loogle-mcp/.env`: + +### Opzione consigliata — token per utente (4 familiari) + +```env +PAPERLESS_URL=https://docs.loogle.it +PAPERLESS_API_TOKEN_DANIELE= +PAPERLESS_API_TOKEN_LUCIA= +PAPERLESS_API_TOKEN_DAVIDE= +PAPERLESS_API_TOKEN_LUCA= +``` + +| Utente MCP | Variabile `.env` | Account Paperless | +|------------|------------------|-------------------| +| Daniele | `PAPERLESS_API_TOKEN_DANIELE` | utente admin / superuser | +| Lucia | `PAPERLESS_API_TOKEN_LUCIA` | utente Lucia | +| Davide | `PAPERLESS_API_TOKEN_DAVIDE` | utente Davide | +| Luca | `PAPERLESS_API_TOKEN_LUCA` | utente Luca | + +Righe vuote = utente saltato dall'indexer finché non inserisci il token. + +### Opzione alternativa — un solo token admin + +Se preferisci un unico account Paperless (es. Daniele superuser): + +```env +PAPERLESS_API_TOKEN= +``` + +Tutti gli utenti MCP useranno quel token in fallback. + +### Opzione C — mappa JSON + +```env +PAPERLESS_API_TOKENS={"daniele":"...","lucia":"...","davide":"...","luca":"..."} +``` + +--- + +## Comportamento nel sistema + +| Componente | Comportamento | +|------------|---------------| +| **Indexer** | Usa tutti i token configurati, unisce l'elenco documenti (deduplica per ID) | +| **`search_knowledge`** | Cerca su tutto ciò che è stato indicizzato | +| **`get_document`** | Usa il token dell'utente MCP loggato; se manca, fallback su `PAPERLESS_API_TOKEN` o token Daniele | + +Con token separati, ogni familiare può scaricare via MCP solo i documenti visibili al proprio account Paperless. + +--- + +## Applicare le modifiche + +```bash +cd /home/daniely/docker/loogle-mcp +# modifica .env con i token reali +docker compose restart loogle-mcp loogle-mcp-indexer +docker logs loogle-mcp-indexer --tail 30 +``` + +Log atteso: indicizzazione da `daniele`, `lucia`, `davide`, `luca` (solo utenti con token impostato). + +--- + +## Checklist onboarding Paperless + +- [ ] Account Paperless creato per daniele, lucia, davide, luca (se non esistono) +- [ ] Token API generato per ciascuno (Profilo → Token API) +- [ ] Quattro righe compilate in `.env` +- [ ] `docker compose restart loogle-mcp loogle-mcp-indexer` +- [ ] Log indexer senza errori 401 diff --git a/services/loogle-mcp/docs/pdf/GUIDA-PAPERLESS-FAMIGLIA.pdf b/services/loogle-mcp/docs/pdf/GUIDA-PAPERLESS-FAMIGLIA.pdf new file mode 100644 index 0000000..7da4a98 Binary files /dev/null and b/services/loogle-mcp/docs/pdf/GUIDA-PAPERLESS-FAMIGLIA.pdf differ diff --git a/services/loogle-mcp/docs/pdf/GUIDA-UTENTI.pdf b/services/loogle-mcp/docs/pdf/GUIDA-UTENTI.pdf new file mode 100644 index 0000000..db8a529 Binary files /dev/null and b/services/loogle-mcp/docs/pdf/GUIDA-UTENTI.pdf differ diff --git a/services/loogle-mcp/docs/pdf/ONBOARDING.pdf b/services/loogle-mcp/docs/pdf/ONBOARDING.pdf new file mode 100644 index 0000000..5dcde04 Binary files /dev/null and b/services/loogle-mcp/docs/pdf/ONBOARDING.pdf differ diff --git a/services/loogle-mcp/requirements.txt b/services/loogle-mcp/requirements.txt new file mode 100644 index 0000000..440b87d --- /dev/null +++ b/services/loogle-mcp/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +python-multipart==0.0.20 +PyJWT==2.10.1 +cryptography==44.0.2 +httpx==0.28.1 +qdrant-client==1.13.3 +requests==2.32.3 +apscheduler==3.10.4 diff --git a/services/loogle-mcp/scripts/deploy.sh b/services/loogle-mcp/scripts/deploy.sh new file mode 100755 index 0000000..e84c829 --- /dev/null +++ b/services/loogle-mcp/scripts/deploy.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +docker compose build +docker compose up -d +echo "Attendo health..." +for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8700/health >/dev/null; then + echo "OK — loogle-mcp attivo su :8700" + exit 0 + fi + sleep 2 +done +echo "Health check fallito — vedi docker logs loogle-mcp" >&2 +exit 1 diff --git a/services/loogle-mcp/scripts/docker-entrypoint.sh b/services/loogle-mcp/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..d282c5e --- /dev/null +++ b/services/loogle-mcp/scripts/docker-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e +mkdir -p /data /data/context + +# Chiave SSH per thermal gate (permessi OpenSSH) +if [ -f /run/secrets/ds920_ssh_key ]; then + mkdir -p /root/.ssh + cp /run/secrets/ds920_ssh_key /root/.ssh/ds920_key + chmod 600 /root/.ssh/ds920_key + export DS920_SSH_KEY=/root/.ssh/ds920_key +fi + +exec "$@" diff --git a/services/loogle-mcp/scripts/ds920_thermal_probe.py b/services/loogle-mcp/scripts/ds920_thermal_probe.py new file mode 100644 index 0000000..2cf1346 --- /dev/null +++ b/services/loogle-mcp/scripts/ds920_thermal_probe.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Probe temperatura/load CPU DS920 — HTTP GET /thermal su :9191. + +Avvio (sul DS920, utente daniely): + nohup python3 ds920_thermal_probe.py >> /tmp/thermal-probe.log 2>&1 & + +O via Task Scheduler Synology all'avvio. +""" + +from __future__ import annotations + +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +PORT = int(os.environ.get("THERMAL_PROBE_PORT", "9191")) +HWMON = os.environ.get("THERMAL_HWMON", "/sys/class/hwmon/hwmon0") + + +def read_metrics() -> dict: + temps = [] + try: + for name in sorted(os.listdir(HWMON)): + if name.startswith("temp") and name.endswith("_input"): + with open(os.path.join(HWMON, name), encoding="utf-8") as f: + temps.append(int(f.read().strip()) / 1000.0) + except OSError: + pass + load1, load5, load15 = os.getloadavg() + nproc = os.cpu_count() or 4 + return { + "ok": True, + "host": os.uname().nodename, + "cpu_temp_c": max(temps) if temps else None, + "temps_c": temps, + "load1": load1, + "load5": load5, + "load15": load15, + "nproc": nproc, + "cpu_target_load": round(nproc * 0.75, 2), + } + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args) -> None: # noqa: A003 + return + + def do_GET(self) -> None: # noqa: N802 + if self.path.split("?")[0] not in ("/", "/thermal", "/health"): + self.send_response(404) + self.end_headers() + return + body = json.dumps(read_metrics()).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def main() -> None: + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"thermal probe listening on 0.0.0.0:{PORT}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/services/loogle-mcp/scripts/grant_family_paperless.py b/services/loogle-mcp/scripts/grant_family_paperless.py new file mode 100644 index 0000000..51b4f3b --- /dev/null +++ b/services/loogle-mcp/scripts/grant_family_paperless.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Grant view permissions on non-personal Paperless documents to family users.""" + +import os +import sys + +import httpx + +PAPERLESS_URL = os.environ.get("PAPERLESS_URL", "https://docs.loogle.it").rstrip("/") +ADMIN_TOKEN = os.environ.get("PAPERLESS_API_TOKEN_DANIELE") or os.environ.get("PAPERLESS_API_TOKEN", "") +FAMILY_USER_IDS = [4, 5, 6] # lucia, davide, luca +PERSONAL_TAG_NAMES = {"personal", "privato", "private"} +BATCH_SIZE = 50 + + +def main() -> int: + if not ADMIN_TOKEN: + print("Missing PAPERLESS_API_TOKEN_DANIELE", file=sys.stderr) + return 1 + headers = {"Authorization": f"Token {ADMIN_TOKEN}", "Content-Type": "application/json"} + + tags_resp = httpx.get(f"{PAPERLESS_URL}/api/tags/", headers=headers, timeout=60) + tags_resp.raise_for_status() + personal_tag_ids = { + t["id"] + for t in tags_resp.json().get("results", []) + if (t.get("name") or "").lower() in PERSONAL_TAG_NAMES + } + + doc_ids: list[int] = [] + page = 1 + while True: + resp = httpx.get( + f"{PAPERLESS_URL}/api/documents/", + headers=headers, + params={"page": page, "page_size": 100}, + timeout=60, + ) + resp.raise_for_status() + data = resp.json() + for doc in data.get("results", []): + if set(doc.get("tags") or []) & personal_tag_ids: + continue + doc_ids.append(doc["id"]) + if not data.get("next"): + break + page += 1 + + print(f"Documenti da condividere (senza tag personali): {len(doc_ids)}") + + updated = 0 + for i in range(0, len(doc_ids), BATCH_SIZE): + chunk = doc_ids[i : i + BATCH_SIZE] + payload = { + "documents": chunk, + "method": "set_permissions", + "parameters": { + "set_permissions": { + "view": {"users": FAMILY_USER_IDS, "groups": []}, + "change": {"users": [], "groups": []}, + }, + "merge": True, + }, + } + resp = httpx.post( + f"{PAPERLESS_URL}/api/documents/bulk_edit/", + headers=headers, + json=payload, + timeout=120, + ) + resp.raise_for_status() + updated += len(chunk) + print(f" Aggiornati {updated}/{len(doc_ids)}") + + for name, env_key in [ + ("lucia", "PAPERLESS_API_TOKEN_LUCIA"), + ("davide", "PAPERLESS_API_TOKEN_DAVIDE"), + ("luca", "PAPERLESS_API_TOKEN_LUCA"), + ]: + token = os.environ.get(env_key, "") + if not token: + continue + count = httpx.get( + f"{PAPERLESS_URL}/api/documents/?page_size=1", + headers={"Authorization": f"Token {token}"}, + timeout=30, + ).json().get("count", 0) + print(f"{name}: documenti visibili = {count}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/run_gitea_index_once.py b/services/loogle-mcp/scripts/run_gitea_index_once.py new file mode 100644 index 0000000..0ec1f17 --- /dev/null +++ b/services/loogle-mcp/scripts/run_gitea_index_once.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Indicizzazione Gitea one-shot — eseguire con indexer fermo per evitare OOM.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +def _load_dotenv() -> None: + env_path = os.path.join(ROOT, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + if key.strip() and key.strip() not in os.environ: + os.environ[key.strip()] = value.strip().strip("'").strip('"') + + +def main() -> int: + parser = argparse.ArgumentParser(description="Indicizza repo Gitea nel vector store") + parser.add_argument("--repo", help="owner/name (default: tutti i repo configurati)") + parser.add_argument("--user", default="daniele", help="Utente MCP/Gitea token") + parser.add_argument("--max-files", type=int, default=0, help="Limite file per repo (0=config)") + parser.add_argument("--force", action="store_true", help="Re-indicizza anche file invariati") + args = parser.parse_args() + + _load_dotenv() + if os.path.isfile("/.dockerenv"): + os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db") + os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db") + + from app.db import init_db + from app.knowledge import gitea_indexer + + init_db() + max_files = args.max_files or None + + if args.repo: + result = gitea_indexer.index_repo( + args.repo, + username=args.user, + force=args.force, + max_files=max_files, + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + result = gitea_indexer.index_all(max_files_per_repo=max_files) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + stats = gitea_indexer.index_stats() + print("stats", json.dumps(stats, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/seed_demo_projects.py b/services/loogle-mcp/scripts/seed_demo_projects.py new file mode 100644 index 0000000..9f05ce1 --- /dev/null +++ b/services/loogle-mcp/scripts/seed_demo_projects.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Crea un progetto demo per ogni utente famiglia.""" +import sys +sys.path.insert(0, "/srv") +from app.context import store as context_store + +USERS = { + "daniele": "Homelab LOOGLE", + "lucia": "Progetti personali", + "davide": "Studio e task", + "luca": "Progetti Luca", +} + +for username, title in USERS.items(): + root = context_store._user_root(username) + existing = context_store.list_projects(username) + if existing: + print(f"{username}: skip ({len(existing)} progetti)") + continue + meta = context_store.create_project(username, title, tags=["demo"]) + context_store.save_context( + username, + meta["id"], + f"Progetto demo iniziale per {username}. Usa save_context per ampliare la memoria agente.", + ) + print(f"{username}: creato {meta['id']}") diff --git a/services/loogle-mcp/scripts/setup_gitea_p4_repos.py b/services/loogle-mcp/scripts/setup_gitea_p4_repos.py new file mode 100644 index 0000000..7a101fd --- /dev/null +++ b/services/loogle-mcp/scripts/setup_gitea_p4_repos.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Crea repo progetti per davide/luca e progetti Loogle collegati.""" + +from __future__ import annotations + +import os +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +def _load_dotenv() -> None: + path = os.path.join(ROOT, ".env") + if not os.path.isfile(path): + return + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + os.environ[k.strip()] = v.strip().strip("'").strip('"') + + +def ensure_repo(user: str, repo_name: str, description: str) -> str: + from app.knowledge import gitea + + gitea._tokens_cache = None + full = f"{user}/{repo_name}" + existing = {r["full_name"] for r in gitea.list_repos(username=user)["repos"]} + if full in existing: + print(f"repo_exists: {full}") + return full + created = gitea.create_repo( + repo_name, + username=user, + private=True, + description=description, + auto_init=True, + ) + print(f"repo_created: {created['full_name']}") + return created["full_name"] + + +def ensure_mcp_project(user: str, project_title: str, gitea_repo: str) -> None: + from app.context import store as context_store + + projects = {p["id"]: p for p in context_store.list_projects(user, include_archived=True)} + for meta in projects.values(): + if meta.get("gitea_repo") == gitea_repo: + print(f"project_linked: {user}/{meta['id']} -> {gitea_repo}") + return + slug = project_title.lower().replace(" ", "-") + if slug in projects: + meta = context_store.link_project_repo(user, slug, gitea_repo=gitea_repo, seed_from_gitea=True) + print(f"project_updated: {user}/{meta['id']} -> {gitea_repo}") + return + meta = context_store.create_project( + user, + project_title, + tags=["gitea", "p4"], + gitea_repo=gitea_repo, + seed_from_gitea=True, + ) + print(f"project_created: {user}/{meta['id']} -> {gitea_repo}") + + +def main() -> int: + _load_dotenv() + os.environ.setdefault("MCP_DB", os.environ.get("MCP_DB", "/data/loogle_mcp.db")) + from app.db import init_db + + init_db() + + ensure_repo( + "davide", + "progetti", + "Workspace personale — codice e note generate con Loogle MCP", + ) + ensure_repo( + "luca", + "progetti", + "Workspace personale — codice e note generate con Loogle MCP", + ) + + ensure_mcp_project("davide", "Progetti Davide", "davide/progetti") + ensure_mcp_project("luca", "Progetti Luca", "luca/progetti") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/test_gitea_integration.py b/services/loogle-mcp/scripts/test_gitea_integration.py new file mode 100755 index 0000000..9f14d79 --- /dev/null +++ b/services/loogle-mcp/scripts/test_gitea_integration.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Test integrazione Gitea MCP — mock server + opzionale live API.""" + +from __future__ import annotations + +import json +import os +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +MOCK_TOKEN = "test-gitea-token" +MOCK_PORT = 18799 + +os.environ["GITEA_URL"] = "https://git.loogle.it" +os.environ["GITEA_API_URL"] = f"http://127.0.0.1:{MOCK_PORT}" +os.environ["GITEA_API_TOKEN_DANIELE"] = MOCK_TOKEN +os.environ["MCP_DB"] = "/tmp/loogle_mcp_test.db" + + +class GiteaMockHandler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + return + + def _auth_ok(self) -> bool: + auth = self.headers.get("Authorization", "") + return auth == f"token {MOCK_TOKEN}" + + def _json(self, code: int, payload): + body = json.dumps(payload).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if not self._auth_ok(): + self._json(401, {"message": "invalid token"}) + return + path = urlparse(self.path).path + qs = parse_qs(urlparse(self.path).query) + + if path == "/api/v1/user/repos": + self._json(200, [ + { + "full_name": "daniele/rete", + "description": "Infra HA", + "private": True, + "html_url": "https://git.loogle.it/daniele/rete", + "default_branch": "main", + "updated_at": "2026-08-22T10:00:00Z", + } + ]) + return + + if path == "/api/v1/repos/daniele/rete/contents/ha/RUNBOOK-failover.md": + content = "# Failover tier-b\n\nloogle-mcp nel pivot tier-b.\n" + import base64 + + self._json( + 200, + { + "type": "file", + "path": "ha/RUNBOOK-failover.md", + "content": base64.b64encode(content.encode()).decode(), + "encoding": "base64", + "size": len(content), + "sha": "abc123", + "html_url": "https://git.loogle.it/daniele/rete/src/branch/main/ha/RUNBOOK-failover.md", + }, + ) + return + + if path == "/api/v1/search/code": + q = (qs.get("q") or [""])[0] + self._json( + 200, + { + "data": [ + { + "repository": {"full_name": "daniele/rete"}, + "path": "ha/RUNBOOK-failover.md", + "sha": "abc123", + "content": f"...{q}...", + } + ] + }, + ) + return + + if path == "/api/v1/repos/daniele/rete/issues": + self._json( + 200, + [ + { + "number": 1, + "title": "Test issue", + "state": "open", + "user": {"login": "daniele"}, + "html_url": "https://git.loogle.it/daniele/rete/issues/1", + "created_at": "2026-08-22T10:00:00Z", + "updated_at": "2026-08-22T10:00:00Z", + "labels": [], + } + ], + ) + return + + if path == "/api/v1/repos/daniele/rete/issues/1": + self._json( + 200, + { + "number": 1, + "title": "Test issue", + "state": "open", + "body": "Corpo issue di test", + "user": {"login": "daniele"}, + "html_url": "https://git.loogle.it/daniele/rete/issues/1", + "created_at": "2026-08-22T10:00:00Z", + "updated_at": "2026-08-22T10:00:00Z", + "labels": [], + }, + ) + return + + self._json(404, {"message": f"not found: {path}"}) + + def do_POST(self): + if not self._auth_ok(): + self._json(401, {"message": "invalid token"}) + return + path = urlparse(self.path).path + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length).decode() or "{}") + if path == "/api/v1/repos/daniele/rete/issues": + self._json( + 201, + { + "number": 42, + "title": body.get("title"), + "state": "open", + "html_url": "https://git.loogle.it/daniele/rete/issues/42", + }, + ) + return + self._json(404, {"message": f"not found: {path}"}) + + +def run_mock_tests() -> None: + from app.db import init_db + from app.knowledge import gitea as gitea_mod + from app.mcp import tools + + init_db() + gitea_mod._tokens_cache = None + + server = HTTPServer(("127.0.0.1", MOCK_PORT), GiteaMockHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + claims = {"sub": "daniele", "scope": "gitea:read gitea:write"} + + repos = tools.call_tool("list_repos", {}, claims) + assert "daniele/rete" in repos["content"][0]["text"] + + file_data = tools.call_tool( + "get_file", + {"repo": "daniele/rete", "path": "ha/RUNBOOK-failover.md"}, + claims, + ) + assert "tier-b" in file_data["content"][0]["text"] + + search = tools.call_tool( + "search_code", + {"query": "tier-b", "repo": "daniele/rete"}, + claims, + ) + assert "RUNBOOK-failover" in search["content"][0]["text"] + + issues = tools.call_tool("list_issues", {"repo": "daniele/rete"}, claims) + assert "Test issue" in issues["content"][0]["text"] + + issue = tools.call_tool( + "get_issue", + {"repo": "daniele/rete", "number": 1}, + claims, + ) + assert "Corpo issue" in issue["content"][0]["text"] + + created = tools.call_tool( + "create_issue", + {"repo": "daniele/rete", "title": "Da MCP", "body": "Test"}, + claims, + ) + assert "42" in created["content"][0]["text"] + + server.shutdown() + print("OK mock: list_repos, get_file, search_code, list_issues, get_issue, create_issue") + + +def _load_dotenv() -> None: + env_path = os.path.join(ROOT, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'").strip('"') + if key.startswith("GITEA_"): + os.environ[key] = value + + +def run_live_tests() -> None: + _load_dotenv() + from app.knowledge import gitea as gitea_mod + from app.mcp import tools + + token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip() + if not token or token == MOCK_TOKEN: + print("SKIP live: GITEA_API_TOKEN_DANIELE non impostato (token reale in .env)") + return + + api_url = gitea_mod.api_base_url() + if str(MOCK_PORT) in api_url: + print("SKIP live: GITEA_API_URL punta al mock") + return + + gitea_mod._tokens_cache = None + claims = {"sub": "daniele", "scope": "gitea:read gitea:write"} + + repos = gitea_mod.list_repos(username="daniele") + if not repos.get("repos"): + raise RuntimeError("live list_repos: nessun repository") + + first = repos["repos"][0]["full_name"] + tools.call_tool("list_repos", {}, claims) + print(f"OK live list_repos: {len(repos['repos'])} repo, primo={first}") + + +def main() -> int: + run_mock_tests() + run_live_tests() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/test_gitea_p4.py b/services/loogle-mcp/scripts/test_gitea_p4.py new file mode 100644 index 0000000..ef15521 --- /dev/null +++ b/services/loogle-mcp/scripts/test_gitea_p4.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Test P4: scrittura Gitea (create repo, create/update file).""" + +from __future__ import annotations + +import json +import os +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +def _load_dotenv() -> None: + path = os.path.join(ROOT, ".env") + if not os.path.isfile(path): + return + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + os.environ[k.strip()] = v.strip().strip("'").strip('"') + + +def main() -> int: + _load_dotenv() + os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db") + + from app.db import init_db + from app.knowledge import gitea + from app.mcp import tools + + init_db() + gitea._tokens_cache = None + + if not os.environ.get("GITEA_API_TOKEN_DANIELE"): + print("ERR: token daniele assente") + return 1 + + claims = {"sub": "daniele", "scope": "gitea:read gitea:write knowledge:read admin"} + + test_repo = "daniele/mcp-p4-test" + test_path = "docs/p4-verify.md" + test_content = "# P4 verify\n\nFile creato da test_gitea_p4.py\n" + + # cleanup file if exists from prior run + try: + gitea.get_file(test_repo, test_path, username="daniele") + gitea.create_or_update_file( + test_repo, + test_path, + test_content + "\n(updated)\n", + "test p4 update", + username="daniele", + ) + action = "update" + except FileNotFoundError: + # ensure repo exists - use temp repo name under daniele + repos = {r["full_name"] for r in gitea.list_repos(username="daniele")["repos"]} + if test_repo not in repos: + created = tools.call_tool( + "create_gitea_repo", + { + "name": "mcp-p4-test", + "private": True, + "description": "Repo temporaneo test P4", + }, + claims, + ) + print("create_repo", created["content"][0]["text"][:200]) + written = tools.call_tool( + "create_or_update_file", + { + "repo": test_repo, + "path": test_path, + "content": test_content, + "message": "test p4 create", + "reindex": False, + }, + claims, + ) + payload = json.loads(written["content"][0]["text"]) + action = payload.get("action") + print("write", action, payload.get("path")) + + read = gitea.get_file(test_repo, test_path, username="daniele") + assert "P4 verify" in read.get("content", ""), read + print("read_ok", read["path"]) + + for user, repo in (("davide", "davide/progetti"), ("luca", "luca/progetti")): + token_key = f"GITEA_API_TOKEN_{user.upper()}" + if not os.environ.get(token_key): + print(f"SKIP {user}: no token") + continue + gitea._tokens_cache = None + listed = gitea.list_repos(username=user) + names = {r["full_name"] for r in listed["repos"]} + if repo not in names: + raise RuntimeError(f"repo mancante per {user}: {repo}") + print(f"{user}_repo_ok", repo) + + print("OK P4: create_or_update_file + repos davide/luca/progetti") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/test_gitea_rag_p3.py b/services/loogle-mcp/scripts/test_gitea_rag_p3.py new file mode 100755 index 0000000..2de5713 --- /dev/null +++ b/services/loogle-mcp/scripts/test_gitea_rag_p3.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Test P3: RAG semantico su file Gitea indicizzati.""" + +from __future__ import annotations + +import json +import os +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +def _load_dotenv() -> None: + env_path = os.path.join(ROOT, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key and key not in os.environ: + os.environ[key] = value.strip().strip("'").strip('"') + + +def _configure_paths() -> str: + in_container = os.path.isfile("/.dockerenv") or ( + os.path.isdir("/data") and os.access("/data", os.W_OK) + ) + if in_container: + os.environ.setdefault("MCP_DB", "/data/loogle_mcp.db") + os.environ.setdefault("MCP_VECTOR_FALLBACK", "/data/vector_fallback.db") + return "container" + os.environ["MCP_DB"] = "/tmp/loogle_mcp_p3_test.db" + os.environ["MCP_VECTOR_FALLBACK"] = "/tmp/loogle_mcp_p3_vectors.db" + return "host" + + +def main() -> int: + _load_dotenv() + mode = _configure_paths() + print(f"mode={mode} db={os.environ['MCP_DB']} vector={os.environ['MCP_VECTOR_FALLBACK']}") + + from app.db import get_conn, init_db + from app.knowledge import gitea_indexer + from app.mcp import tools + + init_db() + if not get_conn().execute( + "SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'" + ).fetchone(): + raise RuntimeError("tabella indexed_gitea_files assente") + + if not os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip(): + print("SKIP: GITEA_API_TOKEN_DANIELE assente") + return 1 + + repo = "daniele/rete" + existing = gitea_indexer.list_indexed_files(limit=5, repo=repo) + stats = gitea_indexer.index_stats() + if len(existing) < 1 or stats.get("qdrant_points", 0) < 1: + index_result = gitea_indexer.index_repo( + repo, + username="daniele", + force=True, + max_files=1, + ) + print("index", json.dumps(index_result, ensure_ascii=False)) + else: + print("index_skip", "already", len(existing), "files") + + files = gitea_indexer.list_indexed_files(limit=5, repo=repo) + if not files: + raise RuntimeError("indexed_gitea_files vuota") + print("indexed_sample", [f["path"] for f in files[:3]]) + + claims = {"sub": "daniele", "scope": "knowledge:read gitea:read admin", "admin": "admin"} + for query in ("failover tier-b", "runbook"): + hits = gitea_indexer.search_gitea_knowledge("daniele", query, limit=5, is_admin=True) + if not hits: + raise RuntimeError(f"search_gitea_knowledge senza risultati per: {query}") + top = hits[0] + print( + f"search_ok[{query}]", + top.get("path") or top.get("title"), + round(float(top.get("score", 0)), 3), + ) + + tool_out = tools.call_tool( + "search_gitea_knowledge", + {"query": "failover", "limit": 5}, + claims, + ) + if not json.loads(tool_out["content"][0]["text"]).get("results"): + raise RuntimeError("tool search_gitea_knowledge vuoto") + + unified = tools.call_tool( + "search_knowledge", + {"query": "FAILOVER CENSIMENTO documenti servizi", "limit": 12}, + claims, + ) + unified_payload = json.loads(unified["content"][0]["text"]) + results = unified_payload.get("results") or [] + sources = set() + for r in results: + if r.get("repo"): + sources.add("gitea") + elif r.get("source"): + sources.add(r["source"]) + else: + sources.add("paperless") + print("search_knowledge_sources", sorted(sources)) + if "gitea" not in sources: + raise RuntimeError("search_knowledge non include risultati Gitea") + + list_tool = tools.call_tool("list_gitea_indexed_files", {"repo": repo, "limit": 5}, claims) + if json.loads(list_tool["content"][0]["text"]).get("count", 0) <= 0: + raise RuntimeError("list_gitea_indexed_files vuoto") + + print("OK P3: search + unified + list (reindex opzionale via tool reindex_gitea_repo)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/test_p5_home.py b/services/loogle-mcp/scripts/test_p5_home.py new file mode 100644 index 0000000..d49bcfa --- /dev/null +++ b/services/loogle-mcp/scripts/test_p5_home.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Verifica P5: tool live Loogle Casa + Home Assistant.""" + +import json +import os +import sys + +sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def main() -> int: + from app.integrations import casa, homeassistant + from app.mcp import tools + + user = os.environ.get("MCP_TEST_USER", "daniele") + claims = { + "sub": user, + "scope": "home:read context:read knowledge:read gitea:read admin", + } + errors = [] + + print("=== P5 integrations ===") + print("casa configured:", casa.is_configured(user)) + print("ha configured:", homeassistant.is_configured()) + + if casa.is_configured(user): + for tool, args in ( + ("get_home_dashboard", {}), + ("get_home_weather", {}), + ("get_network_overview", {}), + ("get_network_failover_status", {}), + ): + try: + out = tools.call_tool(tool, args, claims) + text = out["content"][0]["text"] + data = json.loads(text) + print(f"OK {tool}: keys={list(data.keys())[:8]}") + except Exception as exc: + print(f"FAIL {tool}: {exc}") + errors.append(tool) + else: + errors.append("casa-not-configured") + + if homeassistant.is_configured(): + try: + cfg = homeassistant.get_config() + print(f"OK ha config: version={cfg.get('version')}") + except Exception as exc: + print(f"FAIL ha config: {exc}") + errors.append("ha-config") + try: + out = tools.call_tool("list_ha_entities", {"domain": "switch", "limit": 5}, claims) + data = json.loads(out["content"][0]["text"]) + print(f"OK list_ha_entities: count={len(data.get('entities', []))}") + except Exception as exc: + print(f"FAIL list_ha_entities: {exc}") + errors.append("list_ha_entities") + else: + errors.append("ha-not-configured") + + if errors: + print("P5 FAILED:", errors) + return 1 + print("P5 OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/test_p6_apps.py b/services/loogle-mcp/scripts/test_p6_apps.py new file mode 100644 index 0000000..85f4733 --- /dev/null +++ b/services/loogle-mcp/scripts/test_p6_apps.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Verifica P6: tool live Irrigazione + Turni.""" + +import json +import os +import sys + +sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def main() -> int: + from app.integrations import irrigazione, turni + from app.mcp import tools + + user = os.environ.get("MCP_TEST_USER", "daniele") + claims = { + "sub": user, + "scope": "irrigation:read turni:read home:read admin", + } + errors = [] + + print("=== P6 integrations ===") + print("irrigazione configured:", irrigazione.is_configured(user)) + print("turni configured:", turni.is_configured(user)) + + if irrigazione.is_configured(user): + for tool, args in ( + ("get_irrigation_status", {}), + ("get_irrigation_zones", {}), + ("get_irrigation_history", {"limit": 5}), + ): + try: + out = tools.call_tool(tool, args, claims) + data = json.loads(out["content"][0]["text"]) + print(f"OK {tool}: type={type(data).__name__}") + except Exception as exc: + print(f"FAIL {tool}: {exc}") + errors.append(tool) + else: + errors.append("irrigazione-not-configured") + + try: + out = tools.call_tool("get_turni_status", {}, claims) + data = json.loads(out["content"][0]["text"]) + print(f"OK get_turni_status: build={data.get('buildVersion', '')[:30]}") + except Exception as exc: + print(f"FAIL get_turni_status: {exc}") + errors.append("get_turni_status") + + if turni.is_configured(user): + for tool, args in ( + ("list_turni_doctors", {}), + ("get_my_shifts", {"limit": 10}), + ): + try: + out = tools.call_tool(tool, args, claims) + data = json.loads(out["content"][0]["text"]) + print(f"OK {tool}: keys={list(data.keys())[:6]}") + except Exception as exc: + print(f"FAIL {tool}: {exc}") + errors.append(tool) + else: + errors.append("turni-not-configured") + + if errors: + print("P6 FAILED:", errors) + return 1 + print("P6 OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/test_p7_apps_rag.py b/services/loogle-mcp/scripts/test_p7_apps_rag.py new file mode 100644 index 0000000..2f11486 --- /dev/null +++ b/services/loogle-mcp/scripts/test_p7_apps_rag.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Verifica P7: RAG Irrigazione + Turni.""" + +import json +import os +import sys + +sys.path.insert(0, "/srv" if os.path.isdir("/srv/app") else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def main() -> int: + from app.db import init_db + from app.knowledge import apps_indexer, indexer + from app.mcp import tools + + init_db() + user = os.environ.get("MCP_TEST_USER", "daniele") + claims = { + "sub": user, + "scope": "knowledge:read irrigation:read turni:read admin", + } + errors = [] + + print("=== P7 apps RAG ===") + existing = apps_indexer.list_indexed_records(limit=5) + if len(existing) >= 2: + print(f"skip re-index: {len(existing)} records already present") + result = {"irrigazione": {"indexed": len(existing), "skipped_reindex": True}, "turni": {}} + else: + result = { + "irrigazione": apps_indexer.index_irrigazione(history_limit=0, events_limit=0), + "turni": apps_indexer.index_turni(assignments_limit=3), + } + print("index_all:", json.dumps(result, ensure_ascii=False)[:500]) + + irr = result.get("irrigazione", {}) + turn = result.get("turni", {}) + if irr.get("skipped") and turn.get("skipped"): + print("P7 FAILED: both sources skipped") + return 1 + if irr.get("indexed", 0) + turn.get("indexed", 0) < 2 and not irr.get("skipped_reindex"): + errors.append("insufficient-indexed-records") + + records = apps_indexer.list_indexed_records(limit=10) + print(f"indexed records: {len(records)}") + if not records: + errors.append("no-records") + + try: + hits = apps_indexer.search_apps_knowledge("irrigazione zona prato", limit=3) + print(f"search_apps_knowledge irrigazione: {len(hits)} hits") + if not hits: + errors.append("search-irrigazione-empty") + except Exception as exc: + print(f"FAIL search_apps: {exc}") + errors.append("search_apps") + + try: + out = tools.call_tool("search_apps_knowledge", {"query": "turno guardia", "limit": 3}, claims) + data = json.loads(out["content"][0]["text"]) + print(f"OK tool search_apps_knowledge: {len(data.get('results', []))} hits") + except Exception as exc: + print(f"FAIL tool search_apps_knowledge: {exc}") + errors.append("tool-search") + + try: + combined = indexer.search_knowledge(user, "irrigazione valvola", limit=5, is_admin=True) + apps_hits = [h for h in combined if h.get("source") in ("irrigazione", "turni")] + print(f"search_knowledge includes apps: {len(apps_hits)} app hits / {len(combined)} total") + except Exception as exc: + print(f"FAIL search_knowledge: {exc}") + errors.append("search_knowledge") + + if errors: + print("P7 FAILED:", errors) + return 1 + print("P7 OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/test_p7_step.py b/services/loogle-mcp/scripts/test_p7_step.py new file mode 100644 index 0000000..162a272 --- /dev/null +++ b/services/loogle-mcp/scripts/test_p7_step.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +from app.db import init_db +from app.knowledge.apps_indexer import ( + _summarize_irrigation_status, + _index_text, + list_indexed_records, + search_apps_knowledge, +) +from app.integrations import irrigazione + +init_db() +print("1 db ok") +existing = list_indexed_records(source="irrigazione", limit=1) +if existing and existing[0].get("record_id") == "status-snapshot": + print("2 skip index (status-snapshot exists)") +else: + s = irrigazione.get_status("daniele") + print("3 status ok", len(s)) + text = _summarize_irrigation_status(s) + print("4 summary", len(text)) + r = _index_text("irrigazione", "status-snapshot", "Stato", text) + print("5 indexed", r) +hits = search_apps_knowledge("irrigazione zona", limit=2) +print("6 hits", len(hits)) +print("STEP OK") diff --git a/services/loogle-mcp/scripts/test_project_gitea_p2.py b/services/loogle-mcp/scripts/test_project_gitea_p2.py new file mode 100755 index 0000000..3d69415 --- /dev/null +++ b/services/loogle-mcp/scripts/test_project_gitea_p2.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Test P2: collegamento progetti MCP ↔ repository Gitea.""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +import tempfile + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ["MCP_DB"] = "/tmp/loogle_mcp_p2_test.db" +TEST_ROOT = tempfile.mkdtemp(prefix="mcp-p2-") + + +def _load_dotenv() -> None: + env_path = os.path.join(ROOT, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"')) + + +def run_unit_tests() -> None: + os.environ["MCP_CONTEXT_ROOT"] = TEST_ROOT + from app.db import init_db + from app.context import store as context_store + from app.mcp import tools + + init_db() + claims = { + "sub": "daniele", + "scope": "context:read context:write gitea:read gitea:write", + } + + meta = context_store.create_project( + "daniele", + "Progetto unit test", + tags=["test"], + ) + meta_path = os.path.join(TEST_ROOT, "daniele", "projects", meta["id"], "meta.json") + meta["gitea_repo"] = "daniele/rete" + with open(meta_path, "w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + + ctx = tools.call_tool( + "get_project_context", + {"project_id": meta["id"], "include_gitea": False}, + claims, + ) + payload = json.loads(ctx["content"][0]["text"]) + assert payload["meta"]["gitea_repo"] == "daniele/rete" + + unlinked = tools.call_tool( + "link_project_repo", + {"project_id": meta["id"], "gitea_repo": ""}, + claims, + ) + payload = json.loads(unlinked["content"][0]["text"]) + assert "gitea_repo" not in payload + + print("OK unit: meta gitea_repo + scollegamento locale") + + +def run_live_tests() -> None: + _load_dotenv() + token = os.environ.get("GITEA_API_TOKEN_DANIELE", "").strip() + if not token: + print("SKIP live: GITEA_API_TOKEN_DANIELE assente") + return + + live_root = os.path.join(TEST_ROOT, "live") + os.makedirs(live_root, exist_ok=True) + os.environ["MCP_CONTEXT_ROOT"] = live_root + + from app.context import store as context_store + from app.mcp import tools + from app.knowledge import gitea + + gitea._tokens_cache = None + claims = { + "sub": "daniele", + "scope": "context:read context:write gitea:read gitea:write", + } + + project_id = "rete-ha-p2-test" + proj_dir = os.path.join(live_root, "daniele", "projects", project_id) + if os.path.isdir(proj_dir): + shutil.rmtree(proj_dir) + + created = tools.call_tool( + "create_project", + { + "title": "Rete HA P2 test", + "tags": ["infra", "test"], + "gitea_repo": "daniele/rete", + "seed_from_gitea": True, + }, + claims, + ) + payload = json.loads(created["content"][0]["text"]) + project_id = payload["id"] + assert payload.get("gitea_repo") == "daniele/rete" + assert payload.get("gitea_seeded_at") + + ctx_path = os.path.join(live_root, "daniele", "projects", project_id, "context.md") + context_md = open(ctx_path, encoding="utf-8").read() + assert "seed:gitea daniele/rete" in context_md + assert len(context_md) > 200 + + enriched = tools.call_tool( + "get_project_context", + {"project_id": project_id, "include_gitea": True, "session_limit": 0}, + claims, + ) + data = json.loads(enriched["content"][0]["text"]) + assert data["meta"]["gitea_repo"] == "daniele/rete" + assert data["gitea"]["available"] is True + assert data["gitea"]["readme"] is not None + assert isinstance(data["gitea"].get("docs_files"), list) + + link_existing = tools.call_tool( + "link_project_repo", + {"project_id": project_id, "gitea_repo": "daniele/rete", "seed_from_gitea": True}, + claims, + ) + meta2 = json.loads(link_existing["content"][0]["text"]) + assert "gitea_seeded_at" in meta2 + + shutil.rmtree(proj_dir) + print(f"OK live: create+seed+enrichment su daniele/rete (project {project_id})") + + +def main() -> int: + try: + run_unit_tests() + run_live_tests() + return 0 + finally: + shutil.rmtree(TEST_ROOT, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/loogle-mcp/scripts/verify_p3_container.py b/services/loogle-mcp/scripts/verify_p3_container.py new file mode 100644 index 0000000..b7b4846 --- /dev/null +++ b/services/loogle-mcp/scripts/verify_p3_container.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import json, os, sys +os.environ.setdefault('MCP_DB','/data/loogle_mcp.db') +os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db') +from app.db import init_db, get_conn +from app.knowledge import gitea_indexer +from app.mcp import tools +init_db() +assert get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone() +repo='daniele/rete' +for path in ['ha/FAILOVER-VOLUMES.md','ha/RUNBOOK-failover.md']: + r=gitea_indexer.index_file(repo, path, username='daniele', private=True, force=True) + print('indexed', path, r.get('chunks'), r.get('skipped'), flush=True) +files=gitea_indexer.list_indexed_files(limit=10, repo=repo) +print('db_files', [f['path'] for f in files], flush=True) +for q in ('failover tier-b','runbook'): + hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True) + print('search', q, hits[0]['path'] if hits else None, round(float(hits[0]['score']),3) if hits else None, flush=True) + if not hits: sys.exit(1) +claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'} +for tool,args in [ + ('search_gitea_knowledge',{'query':'failover tier-b','limit':5}), + ('list_gitea_indexed_files',{'repo':repo,'limit':10}), + ('reindex_gitea_repo',{'repo':repo,'max_files':1}), + ('search_knowledge',{'query':'failover keepalived','limit':8}), +]: + out=tools.call_tool(tool,args,claims) + p=json.loads(out['content'][0]['text']) + if tool=='search_knowledge': + src=sorted({r.get('source') for r in p['results']}) + print(tool, src, flush=True) + assert 'gitea' in src + elif tool=='list_gitea_indexed_files': + print(tool, p['count'], flush=True) + else: + print(tool, 'ok', flush=True) +print('OK P3 COMPLETE', flush=True) diff --git a/services/loogle-mcp/scripts/verify_p3_search_only.py b/services/loogle-mcp/scripts/verify_p3_search_only.py new file mode 100644 index 0000000..a1a7f8a --- /dev/null +++ b/services/loogle-mcp/scripts/verify_p3_search_only.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import json, os, sys +os.environ.setdefault('MCP_DB','/data/loogle_mcp.db') +os.environ.setdefault('MCP_VECTOR_FALLBACK','/data/vector_fallback.db') +from app.db import init_db, get_conn +from app.knowledge import gitea_indexer +from app.mcp import tools +init_db() +print('table_ok', bool(get_conn().execute("SELECT 1 FROM sqlite_master WHERE name='indexed_gitea_files'").fetchone()), flush=True) +files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete') +print('indexed', [f['path'] for f in files], flush=True) +if not files: + print('WARN: nessun file indicizzato — indicizzo stacks.conf (piccolo)', flush=True) + r=gitea_indexer.index_file('daniele/rete','ha/stacks.conf', username='daniele', private=True, force=True) + print('new_index', r, flush=True) + files=gitea_indexer.list_indexed_files(limit=10, repo='daniele/rete') +for q in ('failover','censimento','tier-b'): + hits=gitea_indexer.search_gitea_knowledge('daniele', q, limit=3, is_admin=True) + print('search', q, hits[0]['path'] if hits else 'NONE', round(float(hits[0]['score']),3) if hits else None, flush=True) + if not hits and q=='failover': + sys.exit(1) +claims={'sub':'daniele','scope':'knowledge:read gitea:read admin'} +sg=tools.call_tool('search_gitea_knowledge',{'query':'failover tier-b','limit':5},claims) +assert json.loads(sg['content'][0]['text'])['results'] +print('tool search_gitea_knowledge ok', flush=True) +li=tools.call_tool('list_gitea_indexed_files',{'repo':'daniele/rete','limit':10},claims) +print('tool list', json.loads(li['content'][0]['text'])['count'], flush=True) +ri=tools.call_tool('reindex_gitea_repo',{'repo':'daniele/rete','max_files':1},claims) +print('tool reindex', json.loads(ri['content'][0]['text']).get('files_indexed'), flush=True) +sk=tools.call_tool('search_knowledge',{'query':'failover keepalived','limit':8},claims) +src=sorted({r.get('source') for r in json.loads(sk['content'][0]['text'])['results']}) +print('tool search_knowledge sources', src, flush=True) +assert 'gitea' in src +print('OK P3 SEARCH VERIFIED', flush=True) diff --git a/services/loogle-mcp/worker/indexer_main.py b/services/loogle-mcp/worker/indexer_main.py new file mode 100644 index 0000000..6dee2ce --- /dev/null +++ b/services/loogle-mcp/worker/indexer_main.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +"""Background indexer worker — cicli separati Paperless / Gitea / Apps.""" + +import logging +import os +import time + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") +LOGGER = logging.getLogger("loogle_mcp.worker") + +from app.db import init_db # noqa: E402 +from app.knowledge import apps_indexer, gitea_indexer, indexer # noqa: E402 + + +def _run_gitea() -> None: + if os.environ.get("GITEA_INDEX_ENABLED", "yes").strip().lower() in ("0", "false", "no", "off"): + LOGGER.info("Indicizzazione Gitea disabilitata (GITEA_INDEX_ENABLED)") + return + gitea_result = gitea_indexer.index_all() + stats = gitea_indexer.index_stats() + LOGGER.info("Indicizzazione Gitea completata: %s stats=%s", gitea_result, stats) + + +def _run_paperless() -> None: + result = indexer.index_all(max_pages=10) + LOGGER.info("Indicizzazione Paperless completata: %s", result) + + +def _run_apps() -> None: + apps_result = apps_indexer.index_all() + LOGGER.info("Indicizzazione Apps (P7) completata: %s", apps_result) + + +def main() -> None: + init_db() + interval = int(os.environ.get("INDEXER_INTERVAL_MINUTES", "30")) * 60 + LOGGER.info("Indexer avviato, intervallo %ds", interval) + + from app.knowledge import thermal # noqa: WPS433 + + status = thermal.read_status() + if status: + LOGGER.info( + "Thermal probe OK: temp=%.1f°C load1=%.2f nproc=%s source=%s (hard=%.0f°C soft=%.0f°C cpu_target=%.0f%%)", + float(status.get("cpu_temp_c") or 0), + float(status.get("load1") or 0), + status.get("nproc"), + status.get("source"), + thermal.hard_temp_c(), + thermal.soft_temp_c(), + thermal.cpu_target_pct(), + ) + else: + LOGGER.warning("Thermal probe non raggiungibile all'avvio — gate userà delay conservativi") + + # Gitea per primo: repo piccoli, non bloccato da Paperless lento + try: + thermal.wait_for_headroom(context="cycle:gitea-boot") + _run_gitea() + except Exception as exc: + LOGGER.error("Indicizzazione Gitea fallita: %s", exc) + + while True: + try: + thermal.wait_for_headroom(context="cycle:gitea") + _run_gitea() + except Exception as exc: + LOGGER.error("Indicizzazione Gitea fallita: %s", exc) + + try: + thermal.wait_for_headroom(context="cycle:paperless") + _run_paperless() + except Exception as exc: + LOGGER.error("Indicizzazione Paperless fallita: %s", exc) + + try: + thermal.wait_for_headroom(context="cycle:apps") + _run_apps() + except Exception as exc: + LOGGER.error("Indicizzazione Apps fallita: %s", exc) + + time.sleep(interval) + + +if __name__ == "__main__": + main()