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
+
+
+
+
+
+
+
+
+
+
+
+
+
MCP URL: https://mcp.loogle.it/mcp
+
+
+
+
Cambia password
+
+
+
+
+
+
+
+
Admin — revoca refresh token
+
+
+
+
+
+
+
+
+
+
+
Collegamenti AI
+
+ - ChatGPT: Impostazioni → Developer → Add MCP Connector → URL sopra
+ - Claude: Settings → Connectors → Add remote MCP server
+ - Gemini CLI: configura server remoto in settings MCP
+
+
+
+
+
+
+
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()