Backup automatico script del 2026-08-23 12:04
This commit is contained in:
1 parent
11823447a2
commit
795a15a7b6
66 files changed
+8546
No files matched your search
Whitespace-only changes.
@@ -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]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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"])]
|
||||
@@ -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
|
||||
@@ -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.
@@ -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)]
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user