Backup automatico script del 2026-08-23 12:04

This commit is contained in:
daniele committed 2026-08-23 12:04:16 +02:00
1 parent 11823447a2
commit 795a15a7b6
66 files changed
+8546

No files matched your search

View File
Whitespace-only changes.
+34
View File
@@ -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]
+146
View File
@@ -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()
Whitespace-only changes.
@@ -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"<!-- seed:gitea {repo} {path} -->\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 "<!-- seed:gitea " in context_md:
return context_md, False
readme = fetch_readme(username, repo)
if not readme:
return context_md, False
block = readme_seed_block(repo, readme)
if context_md.strip():
return context_md.rstrip() + "\n\n---\n\n" + block, True
return block, True
+252
View File
@@ -0,0 +1,252 @@
# -*- coding: utf-8 -*-
"""Filesystem-backed project context store."""
import json
import os
import re
import secrets
from datetime import datetime
from typing import Optional
from . import gitea_link
from ..knowledge import gitea
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
def _context_root() -> str:
return os.environ.get("MCP_CONTEXT_ROOT", "/data/context")
def _user_root(username: str) -> str:
path = os.path.join(_context_root(), username, "projects")
os.makedirs(path, exist_ok=True)
return path
def _project_dir(username: str, project_id: str) -> str:
if not SLUG_RE.match(project_id):
raise ValueError("ID progetto non valido")
return os.path.join(_user_root(username), project_id)
def _load_meta(username: str, project_id: str) -> tuple[str, dict]:
proj_dir = _project_dir(username, project_id)
meta_path = os.path.join(proj_dir, "meta.json")
if not os.path.isfile(meta_path):
raise FileNotFoundError("Progetto non trovato")
meta = json.load(open(meta_path, encoding="utf-8"))
return meta_path, meta
def _save_meta(meta_path: str, meta: dict) -> None:
meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
def _apply_gitea_link(
username: str,
meta: dict,
gitea_repo: Optional[str],
*,
verify: bool = True,
seed_from_gitea: bool = False,
) -> dict:
repo = gitea_link.normalize_gitea_repo(gitea_repo)
if verify and repo:
if not gitea.is_configured(username):
raise RuntimeError(
"Gitea non configurato per questo utente — impossibile collegare il repository"
)
gitea_link.verify_repo_access(username, repo)
if repo:
meta["gitea_repo"] = repo
else:
meta.pop("gitea_repo", None)
if seed_from_gitea and repo:
proj_dir = _project_dir(username, meta["id"])
ctx_path = os.path.join(proj_dir, "context.md")
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
new_md, changed = gitea_link.seed_context_from_readme(username, repo, context_md)
if changed:
with open(ctx_path, "w", encoding="utf-8") as f:
f.write(new_md)
meta["gitea_seeded_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
return meta
def list_projects(username: str, include_archived: bool = False) -> list:
root = _user_root(username)
projects = []
if not os.path.isdir(root):
return projects
for name in sorted(os.listdir(root)):
meta_path = os.path.join(root, name, "meta.json")
if not os.path.isfile(meta_path):
continue
meta = json.load(open(meta_path, encoding="utf-8"))
if meta.get("archived") and not include_archived:
continue
projects.append(meta)
return projects
def create_project(
username: str,
title: str,
tags: Optional[list[str]] = None,
gitea_repo: Optional[str] = None,
seed_from_gitea: bool = False,
) -> dict:
slug_base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "progetto"
project_id = slug_base[:40]
root = _user_root(username)
while os.path.exists(os.path.join(root, project_id)):
project_id = f"{slug_base[:32]}-{secrets.token_hex(2)}"
proj_dir = _project_dir(username, project_id)
os.makedirs(os.path.join(proj_dir, "sessions"), exist_ok=True)
os.makedirs(os.path.join(proj_dir, "artifacts"), exist_ok=True)
now = datetime.now().astimezone().isoformat(timespec="seconds")
meta = {
"id": project_id,
"title": title.strip(),
"tags": tags or [],
"status": "active",
"archived": False,
"created_at": now,
"updated_at": now,
}
repo = gitea_link.normalize_gitea_repo(gitea_repo)
if repo:
meta["gitea_repo"] = repo
with open(os.path.join(proj_dir, "meta.json"), "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
with open(os.path.join(proj_dir, "context.md"), "w", encoding="utf-8") as f:
f.write(f"# {title.strip()}\n\n")
if repo:
meta = _apply_gitea_link(
username,
meta,
repo,
verify=True,
seed_from_gitea=seed_from_gitea,
)
_save_meta(os.path.join(proj_dir, "meta.json"), meta)
return meta
def link_project_repo(
username: str,
project_id: str,
gitea_repo: Optional[str] = None,
seed_from_gitea: bool = False,
) -> dict:
meta_path, meta = _load_meta(username, project_id)
meta = _apply_gitea_link(
username,
meta,
gitea_repo,
verify=bool(gitea_link.normalize_gitea_repo(gitea_repo)),
seed_from_gitea=seed_from_gitea,
)
_save_meta(meta_path, meta)
return meta
def get_project_context(
username: str,
project_id: str,
session_limit: int = 5,
include_gitea: bool = True,
) -> dict:
proj_dir = _project_dir(username, project_id)
meta_path = os.path.join(proj_dir, "meta.json")
if not os.path.isfile(meta_path):
raise FileNotFoundError("Progetto non trovato")
meta = json.load(open(meta_path, encoding="utf-8"))
ctx_path = os.path.join(proj_dir, "context.md")
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
sessions_dir = os.path.join(proj_dir, "sessions")
sessions = []
if os.path.isdir(sessions_dir):
files = sorted(os.listdir(sessions_dir), reverse=True)[:session_limit]
for fname in files:
path = os.path.join(sessions_dir, fname)
if os.path.isfile(path):
sessions.append({"name": fname, "content": open(path, encoding="utf-8").read()})
result = {"meta": meta, "context_md": context_md, "recent_sessions": sessions}
gitea_repo = meta.get("gitea_repo")
if include_gitea and gitea_repo:
result["gitea"] = gitea_link.project_enrichment(username, gitea_repo)
return result
def save_context(username: str, project_id: str, content: str, mode: str = "append") -> dict:
proj_dir = _project_dir(username, project_id)
meta_path = os.path.join(proj_dir, "meta.json")
if not os.path.isfile(meta_path):
raise FileNotFoundError("Progetto non trovato")
ctx_path = os.path.join(proj_dir, "context.md")
if mode == "replace":
text = content
else:
existing = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
text = existing.rstrip() + "\n\n" + content.strip() + "\n"
with open(ctx_path, "w", encoding="utf-8") as f:
f.write(text)
meta = json.load(open(meta_path, encoding="utf-8"))
_save_meta(meta_path, meta)
snapshot = datetime.now().strftime("%Y%m%d-%H%M%S") + ".md"
with open(os.path.join(proj_dir, "sessions", snapshot), "w", encoding="utf-8") as f:
f.write(content)
return meta
def archive_project(username: str, project_id: str, archived: bool = True) -> dict:
meta_path, meta = _load_meta(username, project_id)
meta["archived"] = archived
meta["status"] = "archived" if archived else "active"
_save_meta(meta_path, meta)
return meta
def list_resources(username: str) -> list:
resources = []
for meta in list_projects(username, include_archived=False):
desc = f"Contesto progetto {meta['id']}"
if meta.get("gitea_repo"):
desc += f" (Gitea: {meta['gitea_repo']})"
resources.append(
{
"uri": f"loogle://context/{username}/{meta['id']}",
"name": meta["title"],
"description": desc,
"mimeType": "text/markdown",
}
)
return resources
def read_resource(username: str, uri: str) -> dict:
prefix = f"loogle://context/{username}/"
if not uri.startswith(prefix):
raise FileNotFoundError("Risorsa non trovata")
project_id = uri[len(prefix):]
data = get_project_context(username, project_id, session_limit=0, include_gitea=False)
text = data["context_md"]
gitea_repo = data["meta"].get("gitea_repo")
if gitea_repo:
text = f"<!-- gitea_repo: {gitea_repo} -->\n\n" + text
return {
"uri": uri,
"mimeType": "text/markdown",
"text": text,
}
+118
View File
@@ -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)
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Client REST verso app homelab LOOGLE."""
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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),
}
+136
View File
@@ -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
Whitespace-only changes.
@@ -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]
@@ -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"])]
+495
View File
@@ -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}",
}
@@ -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)},
}
@@ -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]
@@ -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
@@ -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]
@@ -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
@@ -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
+368
View File
@@ -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'<p class="error">{error}</p>' if error else ""
return f"""<!DOCTYPE html>
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Loogle MCP — Login</title>
<style>
body{{font-family:system-ui,sans-serif;max-width:420px;margin:4rem auto;padding:1rem;background:#0f172a;color:#e2e8f0}}
h1{{font-size:1.4rem}} .card{{background:#1e293b;padding:1.5rem;border-radius:12px}}
label{{display:block;margin:.75rem 0 .25rem}} input{{width:100%;padding:.5rem;border-radius:6px;border:1px solid #334155;background:#0f172a;color:#e2e8f0}}
button{{margin-top:1rem;width:100%;padding:.65rem;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer}}
.error{{color:#f87171}} .hint{{font-size:.85rem;color:#94a3b8;margin-top:1rem}}
</style></head><body>
<h1>Loogle MCP Hub</h1>
<p>Accedi con le credenziali famiglia per collegare Claude, ChatGPT o Gemini.</p>
<div class="card">{err}
<form method="post" action="/authorize">
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="scope" value="{scope}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="code_challenge" value="{code_challenge or ''}">
<input type="hidden" name="code_challenge_method" value="{code_challenge_method or ''}">
<label>Utente</label><input name="username" autocomplete="username" required>
<label>Password</label><input name="password" type="password" autocomplete="current-password" required>
<button type="submit">Autorizza accesso MCP</button>
</form>
<p class="hint">Primo accesso: password = username (es. lucia/lucia). Cambiala dal dashboard.</p>
</div></body></html>"""
Whitespace-only changes.
+78
View File
@@ -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)]
+962
View File
@@ -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)
+322
View File
@@ -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)
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loogle MCP Dashboard</title>
<style>
:root { --bg:#0f172a; --card:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --accent:#2563eb; }
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem; }
h1 { font-size: 1.5rem; margin-bottom: .25rem; }
.sub { color: var(--muted); margin-bottom: 1.5rem; }
.card { background: var(--card); border-radius: 12px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: .55rem 1rem; cursor: pointer; }
button.secondary { background: #334155; }
input { width: 100%; padding: .5rem; border-radius: 6px; border: 1px solid #334155; background: var(--bg); color: var(--text); margin: .35rem 0 .75rem; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th, td { text-align: left; padding: .45rem .25rem; border-bottom: 1px solid #334155; }
.hidden { display: none; }
.error { color: #f87171; }
code { background: #0b1220; padding: .15rem .35rem; border-radius: 4px; font-size: .85rem; }
ul { padding-left: 1.2rem; }
</style>
</head>
<body>
<h1>Loogle MCP Hub</h1>
<p class="sub">Archivio contesto e knowledge base locale per AI famiglia</p>
<div id="loginView" class="card">
<h2>Accedi</h2>
<label>Utente</label>
<input id="loginUser" autocomplete="username">
<label>Password</label>
<input id="loginPass" type="password" autocomplete="current-password">
<button onclick="doLogin()">Entra</button>
<p id="loginErr" class="error hidden"></p>
</div>
<div id="appView" class="hidden">
<div class="card">
<strong id="welcome"></strong>
<button class="secondary" onclick="doLogout()" style="float:right">Esci</button>
<p>MCP URL: <code>https://mcp.loogle.it/mcp</code></p>
</div>
<div class="card" id="pwdCard">
<h3>Cambia password</h3>
<label>Password attuale</label><input id="oldPwd" type="password">
<label>Nuova password</label><input id="newPwd" type="password">
<button onclick="changePwd()">Salva</button>
<p id="pwdMsg"></p>
</div>
<div class="card hidden" id="adminCard">
<h3>Admin — revoca refresh token</h3>
<label>Refresh token</label><input id="revokeToken">
<button onclick="revokeRefresh()">Revoca</button>
<p id="revokeMsg"></p>
</div>
<div class="card">
<h3>Progetti</h3>
<div id="projects"></div>
</div>
<div class="card">
<h3>Audit log recente</h3>
<table><thead><tr><th>Ora</th><th>Utente</th><th>Tool</th><th>Risorsa</th></tr></thead><tbody id="auditBody"></tbody></table>
</div>
<div class="card">
<h3>Collegamenti AI</h3>
<ul>
<li><strong>ChatGPT:</strong> Impostazioni → Developer → Add MCP Connector → URL sopra</li>
<li><strong>Claude:</strong> Settings → Connectors → Add remote MCP server</li>
<li><strong>Gemini CLI:</strong> configura server remoto in settings MCP</li>
</ul>
</div>
</div>
<script>
async function api(path, opts={}) {
const r = await fetch(path, { credentials: 'same-origin', headers: {'Content-Type':'application/json', ...(opts.headers||{})}, ...opts });
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function show(el, on) { document.getElementById(el).classList.toggle('hidden', !on); }
async function boot() {
try {
const me = await api('/api/me');
show('loginView', false); show('appView', true);
document.getElementById('welcome').textContent = 'Ciao ' + me.username + (me.is_admin ? ' (admin)' : '');
if (me.is_admin) document.getElementById('adminCard').classList.remove('hidden');
loadProjects(); loadAudit();
} catch { show('loginView', true); show('appView', false); }
}
async function doLogin() {
try {
await api('/api/login', { method:'POST', body: JSON.stringify({ username: loginUser.value, password: loginPass.value }) });
boot();
} catch (e) {
loginErr.textContent = 'Credenziali non valide'; loginErr.classList.remove('hidden');
}
}
async function doLogout() { await api('/api/logout', { method:'POST', body:'{}' }); boot(); }
async function changePwd() {
try {
await api('/api/password', { method:'POST', body: JSON.stringify({ old_password: oldPwd.value, new_password: newPwd.value }) });
pwdMsg.textContent = 'Password aggiornata';
} catch (e) { pwdMsg.textContent = 'Errore: password attuale errata'; }
}
async function loadProjects() {
const rows = await api('/api/projects');
projects.innerHTML = rows.length ? rows.map(p => `<div><strong>${p.title}</strong> <small>${p.id}</small> — agg. ${p.updated_at||p.created_at}</div>`).join('') : '<em>Nessun progetto</em>';
}
async function loadAudit() {
const rows = await api('/api/audit?limit=30');
auditBody.innerHTML = rows.map(r => `<tr><td>${r.created_at}</td><td>${r.username}</td><td>${r.tool_name}</td><td>${r.resource_id||''}</td></tr>`).join('');
}
async function revokeRefresh() {
try {
await api('/api/admin/revoke-refresh', { method:'POST', body: JSON.stringify({ refresh_token: revokeToken.value }) });
revokeMsg.textContent = 'Token revocato';
} catch (e) { revokeMsg.textContent = 'Errore revoca'; }
}
boot();
</script>
</body>
</html>