7.78.0 — MCP : périmètre du propriétaire du jeton
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s

list_workspaces/get_workspace filtrés par user_workspace_ids ;
collections filtrées par workspace effectif ; hors périmètre =
introuvable, lectures et écritures. Test deux comptes.
This commit is contained in:
Spark
2026-10-22 09:22:19 -04:00
parent f6782ee5b3
commit 133156580c
4 changed files with 101 additions and 18 deletions
+50 -17
View File
@@ -28,15 +28,20 @@ from fastapi.responses import JSONResponse, Response
from app.db import get_conn
from app.routers.public_api import _token_owner
from app.services.collection_lifecycle import (
effective_workspace_sql,
user_workspace_ids,
)
router = APIRouter(tags=["mcp"])
OUTILS = [
{"name": "list_workspaces",
"description": "Liste les WORKSPACES Flowdeck — les conteneurs de pages "
"de l'utilisateur (ex. Techno, Famille Charest), avec leur "
"nombre de pages actives. À ne pas confondre avec les "
"collections (bases de données).",
"description": "Liste les WORKSPACES Flowdeck du propriétaire du jeton "
"(propriétaire ou membre) — ses conteneurs de pages "
"(ex. Techno, Famille Charest), avec leur nombre de pages "
"actives. À ne pas confondre avec les collections "
"(bases de données).",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_workspace",
"description": "Lit un workspace et l'arborescence de ses pages "
@@ -112,19 +117,41 @@ def _page_resume(row) -> dict:
"updated_at": row["updated_at"]}
def _appel_outil(nom: str, args: dict):
def _appel_outil(nom: str, args: dict, user_id: int):
"""Exécute un outil DANS LE PÉRIMÈTRE du propriétaire du jeton.
Correctif 7.78.0 (cas réel de Bruno) : la première version listait
les 52 workspaces de la base — tous les comptes, résidus de tests
compris. Règle : workspaces dont l'utilisateur est propriétaire
ou membre ; collections rattachées (directement ou via leur page
hôte) à l'un de ces workspaces, plus les collections sans aucun
rattachement (héritage global, comme l'API publique historique).
Un objet hors périmètre répond « introuvable », sans fuiter son
existence.
"""
with get_conn() as conn:
accessible = set(user_workspace_ids(conn, user_id))
eff_sql = effective_workspace_sql("c")
def collection_visible(coll_id: int) -> bool:
row = conn.execute(
f"SELECT {eff_sql} AS eff FROM collections c WHERE c.id = ?",
(coll_id,)).fetchone()
if row is None:
return False
return row["eff"] is None or row["eff"] in accessible
if nom == "list_workspaces":
rows = conn.execute(
"SELECT w.id, w.name, w.owner_id, "
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id "
" AND p.deleted_at IS NULL) AS page_count "
"FROM workspaces w ORDER BY w.name").fetchall()
return [dict(r) for r in rows]
return [dict(r) for r in rows if r["id"] in accessible]
if nom == "get_workspace":
ws = conn.execute("SELECT * FROM workspaces WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if ws is None:
if ws is None or ws["id"] not in accessible:
return {"erreur": "workspace introuvable"}
pages = conn.execute(
"SELECT id, title, parent_id, parent_section, updated_at "
@@ -135,17 +162,20 @@ def _appel_outil(nom: str, args: dict):
"pages": [dict(p) for p in pages]}
if nom == "list_collections":
rows = conn.execute(
"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
"WHERE p.collection_id = c.id) AS page_count "
f"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
f"WHERE p.collection_id = c.id) AS page_count, "
f"{eff_sql} AS eff_ws "
"FROM collections c ORDER BY c.name").fetchall()
return [{"id": r["id"], "name": r["name"],
"description": r["description"], "icon": r["icon"],
"is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
"page_count": r["page_count"]} for r in rows]
"page_count": r["page_count"]}
for r in rows
if r["eff_ws"] is None or r["eff_ws"] in accessible]
if nom == "get_collection":
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if coll is None:
if coll is None or not collection_visible(coll["id"]):
return {"erreur": "collection introuvable"}
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id = ? "
@@ -157,7 +187,7 @@ def _appel_outil(nom: str, args: dict):
if nom == "get_page":
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if p is None:
if p is None or not collection_visible(p["collection_id"]):
return {"erreur": "page introuvable"}
res = _page_resume(p)
for cle in ("content", "blocks", "blocks_json"):
@@ -171,7 +201,8 @@ def _appel_outil(nom: str, args: dict):
rows = conn.execute(
"SELECT * FROM collection_pages WHERE title LIKE ? "
"ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall()
return [_page_resume(p) for p in rows]
return [_page_resume(p) for p in rows
if collection_visible(p["collection_id"])]
if nom == "list_repos":
rows = conn.execute(
"SELECT id, name, proj_type, owner, language, description "
@@ -187,7 +218,7 @@ def _appel_outil(nom: str, args: dict):
return {"erreur": "titre vide"}
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(collection_id,)).fetchone()
if coll is None:
if coll is None or not collection_visible(collection_id):
return {"erreur": "collection introuvable"}
props = args.get("properties")
if props is None:
@@ -214,7 +245,7 @@ def _appel_outil(nom: str, args: dict):
return {"erreur": "id requis (entier)"}
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone()
if page is None:
if page is None or not collection_visible(page["collection_id"]):
return {"erreur": "page introuvable"}
title = page["title"]
if "title" in args and args.get("title") is not None:
@@ -281,7 +312,8 @@ def mcp_info():
async def mcp(request: Request):
auth = request.headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
if not token or _token_owner(token) is None:
proprietaire = _token_owner(token) if token else None
if proprietaire is None:
return JSONResponse(
{"jsonrpc": "2.0", "id": None,
"error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}},
@@ -308,7 +340,8 @@ async def mcp(request: Request):
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
if methode == "tools/call":
params = data.get("params") or {}
res = _appel_outil(params.get("name", ""), params.get("arguments") or {})
res = _appel_outil(params.get("name", ""), params.get("arguments") or {},
proprietaire["user_id"])
return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}}